From 7c16b279ae6a113a8eac91892b3efb097b56e5fc Mon Sep 17 00:00:00 2001 From: mes123456 Date: Sat, 15 Aug 2026 17:43:53 -0400 Subject: [PATCH] Advance M7 workflows and release operations --- .gitea/workflows/m6-ci.yml | 102 ++++ README.md | 2 + docs/BLENDER_5_2_FULL_PARITY_WBS.md | 518 ++++++++++++++++++ docs/CURRENT_EXECUTION_PLAN.md | 207 +++++-- docs/PROJECT_STATUS_AND_NEXT_WORK.md | 52 +- docs/status/ci-lane-report.schema.json | 20 + docs/status/release-evidence.json | 28 +- docs/status/v1-acceptance-evidence.json | 302 +++++----- docs/web/CI.md | 47 ++ docs/web/DEPLOYMENT.md | 180 +++++- docs/web/KNOWN_LIMITATIONS.md | 68 +++ docs/web/RELEASE_NOTES.md | 60 ++ docs/web/RELEASE_RECOVERY.md | 105 ++++ docs/web/engine-manifest-v2.md | 61 +++ docs/web/operations-diagnostics.json | 69 +++ docs/web/sbom.spdx.json | 6 +- tests/golden/M6-04A/engine-manifest-v2.json | 54 ++ tools/web/archive-deployment-server.mjs | 70 +++ tools/web/check-binary-archive.mjs | 139 +++++ tools/web/check-ci-failure-report.mjs | 26 + tools/web/check-ci-report.mjs | 92 ++++ tools/web/check-deployment-cache.mjs | 161 ++++++ tools/web/check-deployment-contract.mjs | 5 +- tools/web/check-deployment-http.mjs | 184 +++++++ tools/web/check-deployment-runbook.mjs | 164 ++++++ tools/web/check-engine-variant-install.mjs | 109 ++++ tools/web/check-local-deps.sh | 4 + tools/web/check-manifest-assets.mjs | 14 +- tools/web/check-operations-diagnostics.mjs | 58 ++ tools/web/check-rc-known-limitations.mjs | 50 ++ tools/web/check-rc-manifest.mjs | 75 +++ tools/web/check-rc-release-notes.mjs | 39 ++ tools/web/check-rc-release-recovery.mjs | 39 ++ tools/web/check-rollback-runbook.mjs | 136 +++++ tools/web/check-source-archive.mjs | 165 ++++++ tools/web/check-upgrade-runbook.mjs | 117 ++++ tools/web/check-vdb-availability.mjs | 7 +- tools/web/check-vdb-native-pipeline.mjs | 2 +- tools/web/check-vdb-server-job.mjs | 3 +- tools/web/create-offline-release.mjs | 95 +++- tools/web/create-rc-manifest.mjs | 98 ++++ tools/web/deployment-http-server.mjs | 166 ++++++ tools/web/install-web-engine-assets.sh | 61 ++- tools/web/run-ci-lane.mjs | 186 +++++++ tools/web/run-operations-rehearsal.mjs | 191 +++++++ tools/web/run-v1-acceptance.mjs | 2 +- tools/web/update-engine-manifest.mjs | 53 +- web/app/public/engine-manifest.json | 60 +- .../vendor/blender/pthread/web_engine.js | 19 + .../vendor/blender/pthread/web_engine.wasm | Bin 0 -> 369042 bytes .../vendor/blender/single/web_engine.js | 16 + .../vendor/blender/single/web_engine.wasm | Bin 0 -> 366066 bytes web/app/src/app/App.tsx | 466 +++++++++++++--- web/app/src/app/app-shell.css | 2 + web/app/src/engine-client/WebEngineClient.ts | 29 +- .../browser-engine-variant-session.ts | 139 +++++ .../engine-client/engine-variant-bootstrap.ts | 195 +++++++ web/app/src/platform/capabilities.ts | 40 +- web/app/src/storage/StorageClient.ts | 2 +- web/app/src/storage/opfs-files.ts | 11 +- web/app/src/workers/storage.worker.ts | 24 +- web/app/src/workers/web-engine.worker.ts | 109 +++- web/app/vite.config.ts | 43 +- web/engine/CMakeLists.txt | 6 +- web/package-lock.json | 4 +- web/package.json | 33 +- web/playwright.archive.config.ts | 42 ++ web/playwright.config.ts | 12 +- web/playwright.release.config.ts | 11 +- web/playwright.single-thread.config.ts | 39 ++ web/protocol/dirty-state.ts | 53 ++ web/protocol/engine-variant.ts | 96 ++++ web/protocol/error.ts | 1 + web/protocol/file-byte-reader.ts | 132 +++++ web/protocol/manifest.ts | 258 ++++++++- web/protocol/progress.ts | 4 + web/protocol/project-action-mutex.ts | 79 +++ web/protocol/save-transaction.ts | 81 +++ web/protocol/storage.ts | 2 +- web/protocol/user-action-state.ts | 109 ++++ web/protocol/web-engine.ts | 10 + web/tests/e2e/archive-offline.spec.ts | 147 +++++ web/tests/e2e/dirty-state.spec.ts | 69 +++ web/tests/e2e/engine-upgrade-safety.spec.ts | 92 ++++ web/tests/e2e/file-import-progress.spec.ts | 111 ++++ web/tests/e2e/project-action-mutex.spec.ts | 95 ++++ web/tests/e2e/pthread-engine-isolated.spec.ts | 78 +++ web/tests/e2e/pthread-fallback.spec.ts | 58 ++ web/tests/e2e/save-interruption.spec.ts | 62 +++ .../e2e/single-thread-unisolated.spec.ts | 110 ++++ web/tests/e2e/smoke.spec.ts | 6 +- web/tests/e2e/user-action-state.spec.ts | 69 +++ .../e2e/wasm-threading-capability.spec.ts | 14 +- web/tests/unit/dirty-state.test.mjs | 55 ++ web/tests/unit/engine-manifest-v2.test.mjs | 97 ++++ .../unit/engine-variant-fallback.test.mjs | 249 +++++++++ .../unit/engine-variant-selection.test.mjs | 125 +++++ web/tests/unit/file-byte-reader.test.mjs | 84 +++ web/tests/unit/local-deps.test.mjs | 10 + web/tests/unit/project-action-mutex.test.mjs | 68 +++ web/tests/unit/save-transaction.test.mjs | 66 +++ web/tests/unit/user-action-state.test.mjs | 93 ++++ 后续工作.txt | 216 ++++++++ 103 files changed, 8064 insertions(+), 429 deletions(-) create mode 100644 .gitea/workflows/m6-ci.yml create mode 100644 docs/BLENDER_5_2_FULL_PARITY_WBS.md create mode 100644 docs/status/ci-lane-report.schema.json create mode 100644 docs/web/CI.md create mode 100644 docs/web/KNOWN_LIMITATIONS.md create mode 100644 docs/web/RELEASE_NOTES.md create mode 100644 docs/web/RELEASE_RECOVERY.md create mode 100644 docs/web/engine-manifest-v2.md create mode 100644 docs/web/operations-diagnostics.json create mode 100644 tests/golden/M6-04A/engine-manifest-v2.json create mode 100644 tools/web/archive-deployment-server.mjs create mode 100644 tools/web/check-binary-archive.mjs create mode 100644 tools/web/check-ci-failure-report.mjs create mode 100644 tools/web/check-ci-report.mjs create mode 100644 tools/web/check-deployment-cache.mjs create mode 100644 tools/web/check-deployment-http.mjs create mode 100644 tools/web/check-deployment-runbook.mjs create mode 100644 tools/web/check-engine-variant-install.mjs create mode 100644 tools/web/check-operations-diagnostics.mjs create mode 100644 tools/web/check-rc-known-limitations.mjs create mode 100644 tools/web/check-rc-manifest.mjs create mode 100644 tools/web/check-rc-release-notes.mjs create mode 100644 tools/web/check-rc-release-recovery.mjs create mode 100644 tools/web/check-rollback-runbook.mjs create mode 100644 tools/web/check-source-archive.mjs create mode 100644 tools/web/check-upgrade-runbook.mjs create mode 100644 tools/web/create-rc-manifest.mjs create mode 100644 tools/web/deployment-http-server.mjs create mode 100644 tools/web/run-ci-lane.mjs create mode 100644 tools/web/run-operations-rehearsal.mjs create mode 100644 web/app/public/vendor/blender/pthread/web_engine.js create mode 100644 web/app/public/vendor/blender/pthread/web_engine.wasm create mode 100644 web/app/public/vendor/blender/single/web_engine.js create mode 100644 web/app/public/vendor/blender/single/web_engine.wasm create mode 100644 web/app/src/engine-client/browser-engine-variant-session.ts create mode 100644 web/app/src/engine-client/engine-variant-bootstrap.ts create mode 100644 web/playwright.archive.config.ts create mode 100644 web/playwright.single-thread.config.ts create mode 100644 web/protocol/dirty-state.ts create mode 100644 web/protocol/engine-variant.ts create mode 100644 web/protocol/file-byte-reader.ts create mode 100644 web/protocol/project-action-mutex.ts create mode 100644 web/protocol/save-transaction.ts create mode 100644 web/protocol/user-action-state.ts create mode 100644 web/tests/e2e/archive-offline.spec.ts create mode 100644 web/tests/e2e/dirty-state.spec.ts create mode 100644 web/tests/e2e/engine-upgrade-safety.spec.ts create mode 100644 web/tests/e2e/file-import-progress.spec.ts create mode 100644 web/tests/e2e/project-action-mutex.spec.ts create mode 100644 web/tests/e2e/pthread-engine-isolated.spec.ts create mode 100644 web/tests/e2e/pthread-fallback.spec.ts create mode 100644 web/tests/e2e/save-interruption.spec.ts create mode 100644 web/tests/e2e/single-thread-unisolated.spec.ts create mode 100644 web/tests/e2e/user-action-state.spec.ts create mode 100644 web/tests/unit/dirty-state.test.mjs create mode 100644 web/tests/unit/engine-manifest-v2.test.mjs create mode 100644 web/tests/unit/engine-variant-fallback.test.mjs create mode 100644 web/tests/unit/engine-variant-selection.test.mjs create mode 100644 web/tests/unit/file-byte-reader.test.mjs create mode 100644 web/tests/unit/project-action-mutex.test.mjs create mode 100644 web/tests/unit/save-transaction.test.mjs create mode 100644 web/tests/unit/user-action-state.test.mjs create mode 100644 后续工作.txt diff --git a/.gitea/workflows/m6-ci.yml b/.gitea/workflows/m6-ci.yml new file mode 100644 index 00000000..2e2f33b6 --- /dev/null +++ b/.gitea/workflows/m6-ci.yml @@ -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 diff --git a/README.md b/README.md index b12a50df..718f09e5 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/BLENDER_5_2_FULL_PARITY_WBS.md b/docs/BLENDER_5_2_FULL_PARITY_WBS.md new file mode 100644 index 00000000..687d2027 --- /dev/null +++ b/docs/BLENDER_5_2_FULL_PARITY_WBS.md @@ -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 解包后使用合同服务器启动,不读取工作区文件。 diff --git a/docs/CURRENT_EXECUTION_PLAN.md b/docs/CURRENT_EXECUTION_PLAN.md index c155ec27..7946fb1e 100644 --- a/docs/CURRENT_EXECUTION_PLAN.md +++ b/docs/CURRENT_EXECUTION_PLAN.md @@ -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 条目执行隔离和修复提示。 diff --git a/docs/PROJECT_STATUS_AND_NEXT_WORK.md b/docs/PROJECT_STATUS_AND_NEXT_WORK.md index 67347cef..71620773 100644 --- a/docs/PROJECT_STATUS_AND_NEXT_WORK.md +++ b/docs/PROJECT_STATUS_AND_NEXT_WORK.md @@ -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" ``` diff --git a/docs/status/ci-lane-report.schema.json b/docs/status/ci-lane-report.schema.json new file mode 100644 index 00000000..ab3beb4f --- /dev/null +++ b/docs/status/ci-lane-report.schema.json @@ -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 +} + diff --git a/docs/status/release-evidence.json b/docs/status/release-evidence.json index 38946f03..12ad4ac3 100644 --- a/docs/status/release-evidence.json +++ b/docs/status/release-evidence.json @@ -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" + ] } ] } diff --git a/docs/status/v1-acceptance-evidence.json b/docs/status/v1-acceptance-evidence.json index af6f1592..80736e50 100644 --- a/docs/status/v1-acceptance-evidence.json +++ b/docs/status/v1-acceptance-evidence.json @@ -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 ''\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 ''\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" } ] } diff --git a/docs/web/CI.md b/docs/web/CI.md new file mode 100644 index 00000000..d3d522c4 --- /dev/null +++ b/docs/web/CI.md @@ -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/.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. + diff --git a/docs/web/DEPLOYMENT.md b/docs/web/DEPLOYMENT.md index 64a361fd..3a4cb9f6 100644 --- a/docs/web/DEPLOYMENT.md +++ b/docs/web/DEPLOYMENT.md @@ -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://[: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:`. 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. diff --git a/docs/web/KNOWN_LIMITATIONS.md b/docs/web/KNOWN_LIMITATIONS.md new file mode 100644 index 00000000..bdeafe9d --- /dev/null +++ b/docs/web/KNOWN_LIMITATIONS.md @@ -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. diff --git a/docs/web/RELEASE_NOTES.md b/docs/web/RELEASE_NOTES.md new file mode 100644 index 00000000..784e2178 --- /dev/null +++ b/docs/web/RELEASE_NOTES.md @@ -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). diff --git a/docs/web/RELEASE_RECOVERY.md b/docs/web/RELEASE_RECOVERY.md new file mode 100644 index 00000000..5a4ec53d --- /dev/null +++ b/docs/web/RELEASE_RECOVERY.md @@ -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. diff --git a/docs/web/engine-manifest-v2.md b/docs/web/engine-manifest-v2.md new file mode 100644 index 00000000..3b49f9d4 --- /dev/null +++ b/docs/web/engine-manifest-v2.md @@ -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 篡改响应。 diff --git a/docs/web/operations-diagnostics.json b/docs/web/operations-diagnostics.json new file mode 100644 index 00000000..56a8fbad --- /dev/null +++ b/docs/web/operations-diagnostics.json @@ -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." + } + ] +} diff --git a/docs/web/sbom.spdx.json b/docs/web/sbom.spdx.json index b03c58b4..c56c301a 100644 --- a/docs/web/sbom.spdx.json +++ b/docs/web/sbom.spdx.json @@ -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" } ] }, diff --git a/tests/golden/M6-04A/engine-manifest-v2.json b/tests/golden/M6-04A/engine-manifest-v2.json new file mode 100644 index 00000000..40283172 --- /dev/null +++ b/tests/golden/M6-04A/engine-manifest-v2.json @@ -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" + } + } + } + ] +} diff --git a/tools/web/archive-deployment-server.mjs b/tools/web/archive-deployment-server.mjs new file mode 100644 index 00000000..06e7787f --- /dev/null +++ b/tools/web/archive-deployment-server.mjs @@ -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`); + diff --git a/tools/web/check-binary-archive.mjs b/tools/web/check-binary-archive.mjs new file mode 100644 index 00000000..030baeb8 --- /dev/null +++ b/tools/web/check-binary-archive.mjs @@ -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 }); +} diff --git a/tools/web/check-ci-failure-report.mjs b/tools/web/check-ci-failure-report.mjs new file mode 100644 index 00000000..b5eb302f --- /dev/null +++ b/tools/web/check-ci-failure-report.mjs @@ -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 }); +} + diff --git a/tools/web/check-ci-report.mjs b/tools/web/check-ci-report.mjs new file mode 100644 index 00000000..b3d49bc6 --- /dev/null +++ b/tools/web/check-ci-report.mjs @@ -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`); diff --git a/tools/web/check-deployment-cache.mjs b/tools/web/check-deployment-cache.mjs new file mode 100644 index 00000000..f58de721 --- /dev/null +++ b/tools/web/check-deployment-cache.mjs @@ -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\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 }); +} diff --git a/tools/web/check-deployment-contract.mjs b/tools/web/check-deployment-contract.mjs index 55edcbf4..196aa679 100644 --- a/tools/web/check-deployment-contract.mjs +++ b/tools/web/check-deployment-contract.mjs @@ -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/); diff --git a/tools/web/check-deployment-http.mjs b/tools/web/check-deployment-http.mjs new file mode 100644 index 00000000..ee523924 --- /dev/null +++ b/tools/web/check-deployment-http.mjs @@ -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", "Web Blender")], + ["/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 }); +} diff --git a/tools/web/check-deployment-runbook.mjs b/tools/web/check-deployment-runbook.mjs new file mode 100644 index 00000000..600090bd --- /dev/null +++ b/tools/web/check-deployment-runbook.mjs @@ -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 }); +} diff --git a/tools/web/check-engine-variant-install.mjs b/tools/web/check-engine-variant-install.mjs new file mode 100644 index 00000000..bf23c161 --- /dev/null +++ b/tools/web/check-engine-variant-install.mjs @@ -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 }); +} diff --git a/tools/web/check-local-deps.sh b/tools/web/check-local-deps.sh index 9e5d265e..0c183e28 100755 --- a/tools/web/check-local-deps.sh +++ b/tools/web/check-local-deps.sh @@ -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" diff --git a/tools/web/check-manifest-assets.mjs b/tools/web/check-manifest-assets.mjs index 7b8bf6a5..c2323608 100644 --- a/tools/web/check-manifest-assets.mjs +++ b/tools/web/check-manifest-assets.mjs @@ -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}`); diff --git a/tools/web/check-operations-diagnostics.mjs b/tools/web/check-operations-diagnostics.mjs new file mode 100644 index 00000000..0d1750a8 --- /dev/null +++ b/tools/web/check-operations-diagnostics.mjs @@ -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`); diff --git a/tools/web/check-rc-known-limitations.mjs b/tools/web/check-rc-known-limitations.mjs new file mode 100644 index 00000000..f784d672 --- /dev/null +++ b/tools/web/check-rc-known-limitations.mjs @@ -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`); diff --git a/tools/web/check-rc-manifest.mjs b/tools/web/check-rc-manifest.mjs new file mode 100644 index 00000000..6b7d6dec --- /dev/null +++ b/tools/web/check-rc-manifest.mjs @@ -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`); diff --git a/tools/web/check-rc-release-notes.mjs b/tools/web/check-rc-release-notes.mjs new file mode 100644 index 00000000..fdffdb45 --- /dev/null +++ b/tools/web/check-rc-release-notes.mjs @@ -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`); diff --git a/tools/web/check-rc-release-recovery.mjs b/tools/web/check-rc-release-recovery.mjs new file mode 100644 index 00000000..77ed183c --- /dev/null +++ b/tools/web/check-rc-release-recovery.mjs @@ -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"); diff --git a/tools/web/check-rollback-runbook.mjs b/tools/web/check-rollback-runbook.mjs new file mode 100644 index 00000000..317b635d --- /dev/null +++ b/tools/web/check-rollback-runbook.mjs @@ -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 }); +} diff --git a/tools/web/check-source-archive.mjs b/tools/web/check-source-archive.mjs new file mode 100644 index 00000000..110b2107 --- /dev/null +++ b/tools/web/check-source-archive.mjs @@ -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 }); +} + diff --git a/tools/web/check-upgrade-runbook.mjs b/tools/web/check-upgrade-runbook.mjs new file mode 100644 index 00000000..3e4b9651 --- /dev/null +++ b/tools/web/check-upgrade-runbook.mjs @@ -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 }); +} diff --git a/tools/web/check-vdb-availability.mjs b/tools/web/check-vdb-availability.mjs index b8541ee8..8b9505cf 100644 --- a/tools/web/check-vdb-availability.mjs +++ b/tools/web/check-vdb-availability.mjs @@ -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; diff --git a/tools/web/check-vdb-native-pipeline.mjs b/tools/web/check-vdb-native-pipeline.mjs index 663deaf8..38d1749a 100644 --- a/tools/web/check-vdb-native-pipeline.mjs +++ b/tools/web/check-vdb-native-pipeline.mjs @@ -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"); diff --git a/tools/web/check-vdb-server-job.mjs b/tools/web/check-vdb-server-job.mjs index a86dc6a1..c500ab7b 100644 --- a/tools/web/check-vdb-server-job.mjs +++ b/tools/web/check-vdb-server-job.mjs @@ -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"); diff --git a/tools/web/create-offline-release.mjs b/tools/web/create-offline-release.mjs index 8e26e197..79528872 100644 --- a/tools/web/create-offline-release.mjs +++ b/tools/web/create-offline-release.mjs @@ -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`); diff --git a/tools/web/create-rc-manifest.mjs b/tools/web/create-rc-manifest.mjs new file mode 100644 index 00000000..326c993b --- /dev/null +++ b/tools/web/create-rc-manifest.mjs @@ -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`); diff --git a/tools/web/deployment-http-server.mjs b/tools/web/deployment-http-server.mjs new file mode 100644 index 00000000..15bfd61b --- /dev/null +++ b/tools/web/deployment-http-server.mjs @@ -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`); +} diff --git a/tools/web/install-web-engine-assets.sh b/tools/web/install-web-engine-assets.sh index 559c41d1..c2a662d4 100755 --- a/tools/web/install-web-engine-assets.sh +++ b/tools/web/install-web-engine-assets.sh @@ -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" diff --git a/tools/web/run-ci-lane.mjs b/tools/web/run-ci-lane.mjs new file mode 100644 index 00000000..caa6549e --- /dev/null +++ b/tools/web/run-ci-lane.mjs @@ -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 "); + +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; diff --git a/tools/web/run-operations-rehearsal.mjs b/tools/web/run-operations-rehearsal.mjs new file mode 100644 index 00000000..f3743867 --- /dev/null +++ b/tools/web/run-operations-rehearsal.mjs @@ -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; diff --git a/tools/web/run-v1-acceptance.mjs b/tools/web/run-v1-acceptance.mjs index 635e9258..91bb15ee 100644 --- a/tools/web/run-v1-acceptance.mjs +++ b/tools/web/run-v1-acceptance.mjs @@ -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", diff --git a/tools/web/update-engine-manifest.mjs b/tools/web/update-engine-manifest.mjs index 239f4255..10bda8f9 100644 --- a/tools/web/update-engine-manifest.mjs +++ b/tools/web/update-engine-manifest.mjs @@ -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 "); +const [manifestPath, singleDirectory, pthreadDirectory] = process.argv.slice(2); +if (!manifestPath || !singleDirectory || !pthreadDirectory) { + throw new Error( + "usage: update-engine-manifest.mjs ", + ); } 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`); diff --git a/web/app/public/engine-manifest.json b/web/app/public/engine-manifest.json index 9313d4ef..67c8840d 100644 --- a/web/app/public/engine-manifest.json +++ b/web/app/public/engine-manifest.json @@ -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" + } + } } ] } diff --git a/web/app/public/vendor/blender/pthread/web_engine.js b/web/app/public/vendor/blender/pthread/web_engine.js new file mode 100644 index 00000000..17a821e0 --- /dev/null +++ b/web/app/public/vendor/blender/pthread/web_engine.js @@ -0,0 +1,19 @@ + +var Module = (() => { + var _scriptName = import.meta.url; + + return ( +function(moduleArg = {}) { + var moduleRtn; + +function GROWABLE_HEAP_I8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP8}function GROWABLE_HEAP_U8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU8}function GROWABLE_HEAP_I32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP32}function GROWABLE_HEAP_U32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU32}function GROWABLE_HEAP_F64(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPF64}var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_PTHREAD=ENVIRONMENT_IS_WORKER&&self.name?.startsWith("em-pthread");var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var wasmModule;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}if(ENVIRONMENT_IS_PTHREAD){var wasmPromiseResolve;var wasmPromiseReject;var initializedJS=false;function threadPrintErr(...args){var text=args.join(" ");console.error(text)}if(!Module["printErr"])err=threadPrintErr;function threadAlert(...args){var text=args.join(" ");postMessage({cmd:"alert",text,threadId:_pthread_self()})}self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>new Promise((resolve,reject)=>{wasmPromiseResolve=module=>{var instance=new WebAssembly.Instance(module,getWasmImports());receiveInstance(instance);resolve()};wasmPromiseReject=reject});self.onunhandledrejection=e=>{throw e.reason||e};function handleMessage(e){try{var msgData=e["data"];var cmd=msgData.cmd;if(cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{postMessage({cmd:"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};for(const handler of msgData.handlers){if(!Module[handler]||Module[handler].proxy){Module[handler]=(...args)=>{postMessage({cmd:"callHandler",handler,args})};if(handler=="print")out=Module[handler];if(handler=="printErr")err=Module[handler]}}wasmMemory=msgData.wasmMemory;updateMemoryViews();wasmPromiseResolve(msgData.wasmModule)}else if(cmd==="run"){establishStackSpace(msgData.pthread_ptr);__emscripten_thread_init(msgData.pthread_ptr,0,0,1,0,0);PThread.receiveObjectTransfer(msgData);PThread.threadInitTLS();__emscripten_thread_mailbox_await(msgData.pthread_ptr);if(!initializedJS){initializedJS=true}try{invokeEntryPoint(msgData.start_routine,msgData.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(msgData.target==="setimmediate"){}else if(cmd==="checkMailbox"){if(initializedJS){checkMailbox()}}else if(cmd){err(`worker: received unknown command ${cmd}`);err(msgData)}}catch(ex){__emscripten_thread_crashed();throw ex}}self.onmessage=handleMessage}if(!ENVIRONMENT_IS_PTHREAD){if(Module["wasmMemory"]){wasmMemory=Module["wasmMemory"]}else{var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;wasmMemory=new WebAssembly.Memory({initial:INITIAL_MEMORY/65536,maximum:32768,shared:true})}updateMemoryViews()}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(ENVIRONMENT_IS_PTHREAD)return;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(ENVIRONMENT_IS_PTHREAD)return;var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){assignWasmImports();return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;registerTLSInit(wasmExports["na"]);wasmTable=wasmExports["Z"];addOnInit(wasmExports["S"]);wasmModule=module;removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"],result["module"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var terminateWorker=worker=>{worker.terminate();worker.onmessage=e=>{}};var cleanupThread=pthread_ptr=>{var worker=PThread.pthreads[pthread_ptr];PThread.returnWorkerToPool(worker)};var spawnThread=threadParams=>{var worker=PThread.getNewWorker();if(!worker){return 6}PThread.runningWorkers.push(worker);PThread.pthreads[threadParams.pthread_ptr]=worker;worker.pthread_ptr=threadParams.pthread_ptr;var msg={cmd:"run",start_routine:threadParams.startRoutine,arg:threadParams.arg,pthread_ptr:threadParams.pthread_ptr};worker.postMessage(msg,threadParams.transferList);return 0};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var stackSave=()=>_emscripten_stack_get_current();var stackRestore=val=>__emscripten_stack_restore(val);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var proxyToMainThread=(funcIndex,emAsmAddr,sync,...callArgs)=>{var serializedNumCallArgs=callArgs.length;var sp=stackSave();var args=stackAlloc(serializedNumCallArgs*8);var b=args>>3;for(var i=0;i{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};function exitOnMainThread(returnCode){if(ENVIRONMENT_IS_PTHREAD)return proxyToMainThread(1,0,0,returnCode);_exit(returnCode)}var exitJS=(status,implicit)=>{EXITSTATUS=status;if(ENVIRONMENT_IS_PTHREAD){exitOnMainThread(status);throw"unwind"}_proc_exit(status)};var _exit=exitJS;var PThread={unusedWorkers:[],runningWorkers:[],tlsInitFunctions:[],pthreads:{},init(){if(!ENVIRONMENT_IS_PTHREAD){PThread.initMainThread()}},initMainThread(){var pthreadPoolSize=1;while(pthreadPoolSize--){PThread.allocateUnusedWorker()}addOnPreRun(()=>{addRunDependency("loading-workers");PThread.loadWasmModuleToAllWorkers(()=>removeRunDependency("loading-workers"))})},terminateAllThreads:()=>{for(var worker of PThread.runningWorkers){terminateWorker(worker)}for(var worker of PThread.unusedWorkers){terminateWorker(worker)}PThread.unusedWorkers=[];PThread.runningWorkers=[];PThread.pthreads=[]},returnWorkerToPool:worker=>{var pthread_ptr=worker.pthread_ptr;delete PThread.pthreads[pthread_ptr];PThread.unusedWorkers.push(worker);PThread.runningWorkers.splice(PThread.runningWorkers.indexOf(worker),1);worker.pthread_ptr=0;__emscripten_thread_free_data(pthread_ptr)},receiveObjectTransfer(data){},threadInitTLS(){PThread.tlsInitFunctions.forEach(f=>f())},loadWasmModuleToWorker:worker=>new Promise(onFinishedLoading=>{worker.onmessage=e=>{var d=e["data"];var cmd=d.cmd;if(d.targetThread&&d.targetThread!=_pthread_self()){var targetWorker=PThread.pthreads[d.targetThread];if(targetWorker){targetWorker.postMessage(d,d.transferList)}else{err(`Internal error! Worker sent a message "${cmd}" to target pthread ${d.targetThread}, but that thread no longer exists!`)}return}if(cmd==="checkMailbox"){checkMailbox()}else if(cmd==="spawnThread"){spawnThread(d)}else if(cmd==="cleanupThread"){cleanupThread(d.thread)}else if(cmd==="loaded"){worker.loaded=true;onFinishedLoading(worker)}else if(cmd==="alert"){alert(`Thread ${d.threadId}: ${d.text}`)}else if(d.target==="setimmediate"){worker.postMessage(d)}else if(cmd==="callHandler"){Module[d.handler](...d.args)}else if(cmd){err(`worker sent an unknown command ${cmd}`)}};worker.onerror=e=>{var message="worker sent an error!";err(`${message} ${e.filename}:${e.lineno}: ${e.message}`);throw e};var handlers=[];var knownHandlers=["onExit","onAbort","print","printErr"];for(var handler of knownHandlers){if(Module.propertyIsEnumerable(handler)){handlers.push(handler)}}worker.postMessage({cmd:"load",handlers,wasmMemory,wasmModule})}),loadWasmModuleToAllWorkers(onMaybeReady){if(ENVIRONMENT_IS_PTHREAD){return onMaybeReady()}let pthreadPoolReady=Promise.all(PThread.unusedWorkers.map(PThread.loadWasmModuleToWorker));pthreadPoolReady.then(onMaybeReady)},allocateUnusedWorker(){var worker;var workerOptions={type:"module",name:"em-pthread"};worker=new Worker(new URL("web_engine.js",import.meta.url),workerOptions);PThread.unusedWorkers.push(worker)},getNewWorker(){if(PThread.unusedWorkers.length==0){PThread.allocateUnusedWorker();PThread.loadWasmModuleToWorker(PThread.unusedWorkers[0])}return PThread.unusedWorkers.pop()}};var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var establishStackSpace=pthread_ptr=>{updateMemoryViews();var stackHigh=GROWABLE_HEAP_U32()[pthread_ptr+52>>2];var stackSize=GROWABLE_HEAP_U32()[pthread_ptr+56>>2];var stackLow=stackHigh-stackSize;_emscripten_stack_set_limits(stackHigh,stackLow);stackRestore(stackHigh)};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var invokeEntryPoint=(ptr,arg)=>{runtimeKeepaliveCounter=0;noExitRuntime=0;var result=getWasmTableEntry(ptr)(arg);function finish(result){if(keepRuntimeAlive()){EXITSTATUS=result}else{__emscripten_thread_exit(result)}}finish(result)};var noExitRuntime=Module["noExitRuntime"]||true;var registerTLSInit=tlsInitFunc=>PThread.tlsInitFunctions.push(tlsInitFunc);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){GROWABLE_HEAP_U32()[this.ptr+4>>2]=type}get_type(){return GROWABLE_HEAP_U32()[this.ptr+4>>2]}set_destructor(destructor){GROWABLE_HEAP_U32()[this.ptr+8>>2]=destructor}get_destructor(){return GROWABLE_HEAP_U32()[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;GROWABLE_HEAP_I8()[this.ptr+12]=caught}get_caught(){return GROWABLE_HEAP_I8()[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;GROWABLE_HEAP_I8()[this.ptr+13]=rethrown}get_rethrown(){return GROWABLE_HEAP_I8()[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){GROWABLE_HEAP_U32()[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return GROWABLE_HEAP_U32()[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_init_main_thread_js=tb=>{__emscripten_thread_init(tb,!ENVIRONMENT_IS_WORKER,1,!ENVIRONMENT_IS_WEB,65536,false);PThread.threadInitTLS()};var maybeExit=()=>{if(!keepRuntimeAlive()){try{if(ENVIRONMENT_IS_PTHREAD)__emscripten_thread_exit(EXITSTATUS);else _exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{func();maybeExit()}catch(e){handleException(e)}};var __emscripten_thread_mailbox_await=pthread_ptr=>{if(typeof Atomics.waitAsync==="function"){var wait=Atomics.waitAsync(GROWABLE_HEAP_I32(),pthread_ptr>>2,pthread_ptr);wait.value.then(checkMailbox);var waitingAsync=pthread_ptr+128;Atomics.store(GROWABLE_HEAP_I32(),waitingAsync>>2,1)}};var checkMailbox=()=>{var pthread_ptr=_pthread_self();if(pthread_ptr){__emscripten_thread_mailbox_await(pthread_ptr);callUserCallback(__emscripten_check_mailbox)}};var __emscripten_notify_mailbox_postmessage=(targetThread,currThreadId)=>{if(targetThread==currThreadId){setTimeout(checkMailbox)}else if(ENVIRONMENT_IS_PTHREAD){postMessage({targetThread,cmd:"checkMailbox"})}else{var worker=PThread.pthreads[targetThread];if(!worker){return}worker.postMessage({cmd:"checkMailbox"})}};var proxiedJSCallArgs=[];var __emscripten_receive_on_main_thread_js=(funcIndex,emAsmAddr,callingThread,numCallArgs,args)=>{proxiedJSCallArgs.length=numCallArgs;var b=args>>3;for(var i=0;i{if(!ENVIRONMENT_IS_PTHREAD)cleanupThread(thread);else postMessage({cmd:"cleanupThread",thread})};var __emscripten_thread_set_strongref=thread=>{};var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;err(text)}};var _emscripten_check_blocking_allowed=()=>{};var runtimeKeepalivePush=()=>{runtimeKeepaliveCounter+=1};var _emscripten_exit_with_live_runtime=()=>{runtimeKeepalivePush();throw"unwind"};var _emscripten_get_now=()=>performance.timeOrigin+performance.now();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=GROWABLE_HEAP_U8().length;requestedSize>>>=0;if(requestedSize<=oldSize){return false}var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{GROWABLE_HEAP_I8().set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,GROWABLE_HEAP_U8(),outPtr,maxBytesToWrite);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.buffer instanceof SharedArrayBuffer?heapOrArray.slice(idx,endPtr):heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(GROWABLE_HEAP_U8(),ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};PThread.init();var proxiedFunctionTable=[_proc_exit,exitOnMainThread];var wasmImports;function assignWasmImports(){wasmImports={s:___cxa_begin_catch,x:___cxa_end_catch,b:___cxa_find_matching_catch_2,c:___cxa_find_matching_catch_3,L:___cxa_rethrow,u:___cxa_throw,h:___resumeException,C:__abort_js,I:__emscripten_init_main_thread_js,E:__emscripten_notify_mailbox_postmessage,J:__emscripten_receive_on_main_thread_js,B:__emscripten_thread_cleanup,H:__emscripten_thread_mailbox_await,G:__emscripten_thread_set_strongref,K:_emscripten_check_blocking_allowed,F:_emscripten_exit_with_live_runtime,v:_emscripten_get_now,D:_emscripten_resize_heap,A:_exit,q:invoke_fiii,j:invoke_ii,e:invoke_iii,g:invoke_iiii,P:invoke_iiiiffifffffffi,k:invoke_iiiii,O:invoke_iiiiifi,m:invoke_iiiiii,r:invoke_iiiiiii,Q:invoke_iiiiiiifii,N:invoke_iiiiiiii,w:invoke_iiiiiiiii,R:invoke_iiiiiiiiii,z:invoke_jiii,p:invoke_v,n:invoke_vi,d:invoke_vii,i:invoke_viii,f:invoke_viiii,l:invoke_viiiii,o:invoke_viiiiii,M:invoke_viiiiiii,y:invoke_viiij,t:_llvm_eh_typeid_for,a:wasmMemory}}var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["S"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["T"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["U"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["V"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["W"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["X"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["Y"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["_"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["$"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["aa"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["ba"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["ca"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["da"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["ea"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["fa"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["ga"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["ha"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["ia"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["ja"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["ka"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["la"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["ma"])();var __emscripten_tls_init=()=>(__emscripten_tls_init=wasmExports["na"])();var _pthread_self=()=>(_pthread_self=wasmExports["oa"])();var __emscripten_thread_init=(a0,a1,a2,a3,a4,a5)=>(__emscripten_thread_init=wasmExports["pa"])(a0,a1,a2,a3,a4,a5);var __emscripten_thread_crashed=()=>(__emscripten_thread_crashed=wasmExports["qa"])();var __emscripten_run_on_main_thread_js=(a0,a1,a2,a3,a4)=>(__emscripten_run_on_main_thread_js=wasmExports["ra"])(a0,a1,a2,a3,a4);var __emscripten_thread_free_data=a0=>(__emscripten_thread_free_data=wasmExports["sa"])(a0);var __emscripten_thread_exit=a0=>(__emscripten_thread_exit=wasmExports["ta"])(a0);var __emscripten_check_mailbox=()=>(__emscripten_check_mailbox=wasmExports["ua"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["va"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["wa"])(a0);var _emscripten_stack_set_limits=(a0,a1)=>(_emscripten_stack_set_limits=wasmExports["xa"])(a0,a1);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ya"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["za"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["Aa"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["Ba"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["Ca"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["Da"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["Ea"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["Fa"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["Ga"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["PThread"]=PThread;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(ENVIRONMENT_IS_PTHREAD){readyPromiseResolve(Module);initRuntime();startWorker(Module);return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); +export default Module; +var isPthread = globalThis.self?.name?.startsWith('em-pthread'); +// When running as a pthread, construct a new instance on startup +isPthread && Module(); diff --git a/web/app/public/vendor/blender/pthread/web_engine.wasm b/web/app/public/vendor/blender/pthread/web_engine.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ab66ed0dc5ef422dcdfe0e7ab10c0fa20313a40a GIT binary patch literal 369042 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

7m)6HC6m_2 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 G@_zxb<8guj literal 0 HcmV?d00001 diff --git a/web/app/public/vendor/blender/single/web_engine.js b/web/app/public/vendor/blender/single/web_engine.js new file mode 100644 index 00000000..dd482cba --- /dev/null +++ b/web/app/public/vendor/blender/single/web_engine.js @@ -0,0 +1,16 @@ + +var Module = (() => { + var _scriptName = import.meta.url; + + return ( +function(moduleArg = {}) { + var moduleRtn; + +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["I"];updateMemoryViews();wasmTable=wasmExports["Q"];addOnInit(wasmExports["J"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={r:___cxa_begin_catch,v:___cxa_end_catch,a:___cxa_find_matching_catch_2,b:___cxa_find_matching_catch_3,B:___cxa_rethrow,t:___cxa_throw,g:___resumeException,y:__abort_js,A:__emscripten_memcpy_js,z:_emscripten_resize_heap,p:invoke_fiii,i:invoke_ii,d:invoke_iii,f:invoke_iiii,F:invoke_iiiiffifffffffi,j:invoke_iiiii,E:invoke_iiiiifi,l:invoke_iiiiii,q:invoke_iiiiiii,G:invoke_iiiiiiifii,D:invoke_iiiiiiii,u:invoke_iiiiiiiii,H:invoke_iiiiiiiiii,x:invoke_jiii,o:invoke_v,m:invoke_vi,c:invoke_vii,h:invoke_viii,e:invoke_viiii,k:invoke_viiiii,n:invoke_viiiiii,C:invoke_viiiiiii,w:invoke_viiij,s:_llvm_eh_typeid_for};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["J"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["K"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["L"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["M"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["N"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["O"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["P"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["R"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["S"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["T"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["U"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["V"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["W"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["X"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["Y"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["Z"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["_"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["$"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["aa"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["ba"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["ca"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["da"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["ea"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["fa"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ga"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["ha"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["ia"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["ja"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["ka"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["la"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["ma"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["na"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["oa"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); +export default Module; diff --git a/web/app/public/vendor/blender/single/web_engine.wasm b/web/app/public/vendor/blender/single/web_engine.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3cf74860ef70facfc5389e3b55c914dcd393c0ab GIT binary patch literal 366066 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 { 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

    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" : ""}`}>·{node.name}
    ; + return
    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" : ""}`}>·{node.name}
    ; })} ))} @@ -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 | null>(null); const [openProgress, setOpenProgress] = useState(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(() => createSaveTransactionState({ revision: 0, sha256: null })); + const [userActions, dispatchUserAction] = useReducer(reduceUserActionStates, createInitialUserActionStates()); + const [projectActionConflict, setProjectActionConflict] = useState(null); const webClientRef = useRef(null); const storageClientRef = useRef(null); const autosaveRef = useRef(null); const commandCountRef = useRef(0); - const projectIdRef = useRef("untitled"); + const projectIdRef = useRef(loadLastProjectId()); + const projectRevisionRef = useRef(0); + const committedProjectRevisionRef = useRef(0); + const committedProjectHashRef = useRef(null); + const userActionSequenceRef = useRef(0); + const projectActionMutexRef = useRef(createProjectActionMutexState()); + const openAbortControllerRef = useRef(null); const fileInputRef = useRef(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: 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 => { + const applyEditCommand = async (command: WebEngineEditCommand): Promise => { 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 => { + 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 => { + 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 => { 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 => { @@ -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 => { + 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 => { - 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 ( -
    +
    Web Blender Modeler V1
    -
    +
    { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
    -
    {workspaceLabel}{uiState.context.mode === "Edit" ? <>
    {(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
    {(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => )} : <>}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}
    +
    {workspaceLabel}{uiState.context.mode === "Edit" ? <>
    {(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
    {(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => )} : <>}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
    void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> @@ -1360,7 +1698,7 @@ export function App() { void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} />
    {uiState.operatorSearchOpen ? dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null} -
    Web Blender Modeler V1Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? {openProgress.message ?? "Opening"} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus}
    +
    Web Blender Modeler V1Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus}
    ); } diff --git a/web/app/src/app/app-shell.css b/web/app/src/app/app-shell.css index c671738f..dc034160 100644 --- a/web/app/src/app/app-shell.css +++ b/web/app/src/app/app-shell.css @@ -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; } diff --git a/web/app/src/engine-client/WebEngineClient.ts b/web/app/src/engine-client/WebEngineClient.ts index d15ea4d7..c357c72c 100644 --- a/web/app/src/engine-client/WebEngineClient.ts +++ b/web/app/src/engine-client/WebEngineClient.ts @@ -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; + 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 { + async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise { 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 { + 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 { const worker = this.start(); const requestId = `web-engine-${++this.requestCounter}`; const request = { requestId, command } as WebEngineRequest; return new Promise((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(); diff --git a/web/app/src/engine-client/browser-engine-variant-session.ts b/web/app/src/engine-client/browser-engine-variant-session.ts new file mode 100644 index 00000000..01cdb22a --- /dev/null +++ b/web/app/src/engine-client/browser-engine-variant-session.ts @@ -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; + +async function verifiedBytes(url: string, expectedSha256: string, controllers: Set): Promise { + 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 { + readonly variant: WebEngineVariantV2; + private readonly module: BrowserVariantModule; + private readonly pendingRequests: Set; + private readonly timers = new Set>(); + private handle: number; + + constructor( + variant: WebEngineVariantV2, + module: BrowserVariantModule, + handle: number, + pendingRequests: Set, + ) { + this.variant = variant; + this.module = module; + this.handle = handle; + this.pendingRequests = pendingRequests; + } + + async openProject(project: ArrayBuffer): Promise { + 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 { + 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> { + const controllers = new Set(); + const verifiedResources = new Map(); + const verify = async (resource: { url: string; sha256: string }): Promise => { + 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); +} diff --git a/web/app/src/engine-client/engine-variant-bootstrap.ts b/web/app/src/engine-client/engine-variant-bootstrap.ts new file mode 100644 index 00000000..2907ffd7 --- /dev/null +++ b/web/app/src/engine-client/engine-variant-bootstrap.ts @@ -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 { + readonly variant: WebEngineVariantV2; + openProject(project: Project): Promise; + dispose(): Promise; + 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 { + result: EngineVariantBootstrapResult; + session: EngineVariantSession; +} + +export type WebEngineRefreshRequired = Extract; + +export type WebEngineReleaseBootstrapOutcome = + | EngineVariantBootstrapOutcome + | { result: WebEngineRefreshRequired; session: null }; + +export interface EngineVariantBootstrapDependencies { + initialize(variant: WebEngineVariantV2): Promise>; +} + +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( + manifest: WebEngineManifestV2, + policy: WebEngineVariantPolicy, + capabilities: WasmThreadingCapabilities, + dependencies: EngineVariantBootstrapDependencies, + pendingProject?: Project, + testHooks?: EngineVariantBootstrapTestHooks, +): Promise> { + 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 | null = null; + + const initialize = async (variant: WebEngineVariantV2): Promise> => { + 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( + expectedReleaseId: string, + manifest: WebEngineManifestV2, + policy: WebEngineVariantPolicy, + capabilities: WasmThreadingCapabilities, + dependencies: EngineVariantBootstrapDependencies, + pendingProject?: Project, + testHooks?: EngineVariantBootstrapTestHooks, +): Promise> { + 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"; +} diff --git a/web/app/src/platform/capabilities.ts b/web/app/src/platform/capabilities.ts index e1ca0d33..9aec0d26 100644 --- a/web/app/src/platform/capabilities.ts +++ b/web/app/src/platform/capabilities.ts @@ -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; diff --git a/web/app/src/storage/StorageClient.ts b/web/app/src/storage/StorageClient.ts index 8ad466cb..1a6ef4b5 100644 --- a/web/app/src/storage/StorageClient.ts +++ b/web/app/src/storage/StorageClient.ts @@ -43,7 +43,7 @@ export class StorageClient { return this.request({ type: "ensureProject", projectId }) as Promise; } - saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "quota"): Promise { + saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise { return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise; } diff --git a/web/app/src/storage/opfs-files.ts b/web/app/src/storage/opfs-files.ts index 8b3b2145..a2d69723 100644 --- a/web/app/src/storage/opfs-files.ts +++ b/web/app/src/storage/opfs-files.ts @@ -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 { 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, diff --git a/web/app/src/workers/storage.worker.ts b/web/app/src/workers/storage.worker.ts index 4b3f4da3..513a56b7 100644 --- a/web/app/src/workers/storage.worker.ts +++ b/web/app/src/workers/storage.worker.ts @@ -199,12 +199,12 @@ async function ensureProject(projectId: string): Promise { }; } -async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "quota"): Promise { +async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "before-metadata-commit" | "quota"): Promise { 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"); diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index 7c3be5e4..b2434751 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -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 | null = null; let currentGeometryBuffers: MeshGeometryBuffer[] = []; let sourceBlendBuffer: ArrayBuffer | null = null; let sourceBlendRevision = -1; +const cancelledOpenRequests = new Set(); +const activeOpenResources = new Map(); + +function openResourceStatus(): WebEngineOpenResourceStatus { + return [...activeOpenResources.values()].reduce((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 { 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((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(); { diff --git a/web/app/vite.config.ts b/web/app/vite.config.ts index 525518f3..e1d88209 100644 --- a/web/app/vite.config.ts +++ b/web/app/vite.config.ts @@ -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 } }; const isolationHeaders = deploymentContract.responseHeaders.allResponses; +const runtimeEnvironment = (globalThis as { process?: { env?: Record } }).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 } }).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", diff --git a/web/engine/CMakeLists.txt b/web/engine/CMakeLists.txt index 4567c51b..10bc509b 100644 --- a/web/engine/CMakeLists.txt +++ b/web/engine/CMakeLists.txt @@ -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") diff --git a/web/package-lock.json b/web/package-lock.json index e86fd4c6..a8df1ac7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 5c7177c0..5ebc7dc9 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/playwright.archive.config.ts b/web/playwright.archive.config.ts new file mode 100644 index 00000000..0f6fa762 --- /dev/null +++ b/web/playwright.archive.config.ts @@ -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, + }, +}); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 0a47400d..ebe153ef 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -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", diff --git a/web/playwright.release.config.ts b/web/playwright.release.config.ts index 457e3d92..08a1aa92 100644 --- a/web/playwright.release.config.ts +++ b/web/playwright.release.config.ts @@ -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, diff --git a/web/playwright.single-thread.config.ts b/web/playwright.single-thread.config.ts new file mode 100644 index 00000000..8e8ebdc7 --- /dev/null +++ b/web/playwright.single-thread.config.ts @@ -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, + }, +}); diff --git a/web/protocol/dirty-state.ts b/web/protocol/dirty-state.ts new file mode 100644 index 00000000..efe334ee --- /dev/null +++ b/web/protocol/dirty-state.ts @@ -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, + }, + }; +} diff --git a/web/protocol/engine-variant.ts b/web/protocol/engine-variant.ts new file mode 100644 index 00000000..e5130573 --- /dev/null +++ b/web/protocol/engine-variant.ts @@ -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"); +} diff --git a/web/protocol/error.ts b/web/protocol/error.ts index fde62061..4f6775dc 100644 --- a/web/protocol/error.ts +++ b/web/protocol/error.ts @@ -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" diff --git a/web/protocol/file-byte-reader.ts b/web/protocol/file-byte-reader.ts new file mode 100644 index 00000000..e7e8a663 --- /dev/null +++ b/web/protocol/file-byte-reader.ts @@ -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; +} + +export interface FileByteReadOptions { + signal: AbortSignal; + onProgress?: (progress: FileReadProgress) => void; + onResourceState?: (state: FileByteReadResourceState) => void; + yieldControl?: () => Promise; + 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 { + 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 }); + } +} diff --git a/web/protocol/manifest.ts b/web/protocol/manifest.ts index 62706322..c0bfe5b6 100644 --- a/web/protocol/manifest.ts +++ b/web/protocol/manifest.ts @@ -19,26 +19,256 @@ export interface WebEngineManifest { wasm: WasmResource[]; } -export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise { - 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; - 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 { +export interface WebEngineVariantMemoryV2 { + initialPages: number; + maximumPages: number; + shared: Shared; +} + +export interface WebEngineSingleVariantV2 { + id: "single"; + memory: WebEngineVariantMemoryV2; + resources: { + js: WebEngineVariantResourceV2; + wasm: WebEngineVariantResourceV2; + pthreadWorker?: never; + }; +} + +export interface WebEnginePthreadVariantV2 { + id: "pthread"; + memory: WebEngineVariantMemoryV2; + 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 { + 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, 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 { + 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; + 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 & Partial>, +): Promise { 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}`); } } diff --git a/web/protocol/progress.ts b/web/protocol/progress.ts index ced112d5..a99ba304 100644 --- a/web/protocol/progress.ts +++ b/web/protocol/progress.ts @@ -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; } diff --git a/web/protocol/project-action-mutex.ts b/web/protocol/project-action-mutex.ts new file mode 100644 index 00000000..eadee444 --- /dev/null +++ b/web/protocol/project-action-mutex.ts @@ -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>>> = { + 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 }; +} diff --git a/web/protocol/save-transaction.ts b/web/protocol/save-transaction.ts new file mode 100644 index 00000000..c9b4e22b --- /dev/null +++ b/web/protocol/save-transaction.ts @@ -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 } }; +} diff --git a/web/protocol/storage.ts b/web/protocol/storage.ts index 3d0932b1..b6551e9d 100644 --- a/web/protocol/storage.ts +++ b/web/protocol/storage.ts @@ -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 } diff --git a/web/protocol/user-action-state.ts b/web/protocol/user-action-state.ts new file mode 100644 index 00000000..b14141e5 --- /dev/null +++ b/web/protocol/user-action-state.ts @@ -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; + +export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly> = { + 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; +} diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts index 75bb5602..710540ba 100644 --- a/web/protocol/web-engine.ts +++ b/web/protocol/web-engine.ts @@ -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 = diff --git a/web/tests/e2e/archive-offline.spec.ts b/web/tests/e2e/archive-offline.spec.ts new file mode 100644 index 00000000..d49980ba --- /dev/null +++ b/web/tests/e2e/archive-offline.spec.ts @@ -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 { + 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 { + 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([]); +}); + diff --git a/web/tests/e2e/dirty-state.spec.ts b/web/tests/e2e/dirty-state.spec.ts new file mode 100644 index 00000000..4762310a --- /dev/null +++ b/web/tests/e2e/dirty-state.spec.ts @@ -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 ?? ""); +}); diff --git a/web/tests/e2e/engine-upgrade-safety.spec.ts b/web/tests/e2e/engine-upgrade-safety.spec.ts new file mode 100644 index 00000000..99d393ba --- /dev/null +++ b/web/tests/e2e/engine-upgrade-safety.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/file-import-progress.spec.ts b/web/tests/e2e/file-import-progress.spec.ts new file mode 100644 index 00000000..78335e5e --- /dev/null +++ b/web/tests/e2e/file-import-progress.spec.ts @@ -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('[data-testid="open-progress"]'); + const bytesRead = Number(element?.getAttribute("data-bytes-read")); + const actualTotal = Number(element?.getAttribute("data-total-bytes")); + const cancel = document.querySelector('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('[data-testid="open-progress"]'); + const stage = progress?.dataset.stage; + const cancel = document.querySelector('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; +}); diff --git a/web/tests/e2e/project-action-mutex.spec.ts b/web/tests/e2e/project-action-mutex.spec.ts new file mode 100644 index 00000000..8551db3d --- /dev/null +++ b/web/tests/e2e/project-action-mutex.spec.ts @@ -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 { + 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("[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('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('button[aria-label="保存项目"]'); + const close = document.querySelector('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(); +}); diff --git a/web/tests/e2e/pthread-engine-isolated.spec.ts b/web/tests/e2e/pthread-engine-isolated.spec.ts new file mode 100644 index 00000000..f8f18296 --- /dev/null +++ b/web/tests/e2e/pthread-engine-isolated.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/pthread-fallback.spec.ts b/web/tests/e2e/pthread-fallback.spec.ts new file mode 100644 index 00000000..2e76fe88 --- /dev/null +++ b/web/tests/e2e/pthread-fallback.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/save-interruption.spec.ts b/web/tests/e2e/save-interruption.spec.ts new file mode 100644 index 00000000..c9be1db0 --- /dev/null +++ b/web/tests/e2e/save-interruption.spec.ts @@ -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); +}); diff --git a/web/tests/e2e/single-thread-unisolated.spec.ts b/web/tests/e2e/single-thread-unisolated.spec.ts new file mode 100644 index 00000000..bdacde73 --- /dev/null +++ b/web/tests/e2e/single-thread-unisolated.spec.ts @@ -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[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"); +}); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 6d76b774..37ac8137 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -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 }) => { diff --git a/web/tests/e2e/user-action-state.spec.ts b/web/tests/e2e/user-action-state.spec.ts new file mode 100644 index 00000000..67911952 --- /dev/null +++ b/web/tests/e2e/user-action-state.spec.ts @@ -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"); +}); diff --git a/web/tests/e2e/wasm-threading-capability.spec.ts b/web/tests/e2e/wasm-threading-capability.spec.ts index 3fc2008a..454c3708 100644 --- a/web/tests/e2e/wasm-threading-capability.spec.ts +++ b/web/tests/e2e/wasm-threading-capability.spec.ts @@ -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", diff --git a/web/tests/unit/dirty-state.test.mjs b/web/tests/unit/dirty-state.test.mjs new file mode 100644 index 00000000..35ce19ca --- /dev/null +++ b/web/tests/unit/dirty-state.test.mjs @@ -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 }); +}); diff --git a/web/tests/unit/engine-manifest-v2.test.mjs b/web/tests/unit/engine-manifest-v2.test.mjs new file mode 100644 index 00000000..52f3f577 --- /dev/null +++ b/web/tests/unit/engine-manifest-v2.test.mjs @@ -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); +}); diff --git a/web/tests/unit/engine-variant-fallback.test.mjs b/web/tests/unit/engine-variant-fallback.test.mjs new file mode 100644 index 00000000..ec9d845f --- /dev/null +++ b/web/tests/unit/engine-variant-fallback.test.mjs @@ -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, []); +}); diff --git a/web/tests/unit/engine-variant-selection.test.mjs b/web/tests/unit/engine-variant-selection.test.mjs new file mode 100644 index 00000000..46ca8538 --- /dev/null +++ b/web/tests/unit/engine-variant-selection.test.mjs @@ -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, + }); +}); diff --git a/web/tests/unit/file-byte-reader.test.mjs b/web/tests/unit/file-byte-reader.test.mjs new file mode 100644 index 00000000..7cb8123a --- /dev/null +++ b/web/tests/unit/file-byte-reader.test.mjs @@ -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", + ); +}); diff --git a/web/tests/unit/local-deps.test.mjs b/web/tests/unit/local-deps.test.mjs index e9c409fc..ff58332a 100644 --- a/web/tests/unit/local-deps.test.mjs +++ b/web/tests/unit/local-deps.test.mjs @@ -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")); diff --git a/web/tests/unit/project-action-mutex.test.mjs b/web/tests/unit/project-action-mutex.test.mjs new file mode 100644 index 00000000..4ef01af7 --- /dev/null +++ b/web/tests/unit/project-action-mutex.test.mjs @@ -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, + }); +}); diff --git a/web/tests/unit/save-transaction.test.mjs b/web/tests/unit/save-transaction.test.mjs new file mode 100644 index 00000000..498e624c --- /dev/null +++ b/web/tests/unit/save-transaction.test.mjs @@ -0,0 +1,66 @@ +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/save-transaction.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 { + SAVE_ATTEMPT_STAGES, + advanceSaveTransaction, + beginSaveTransaction, + commitSaveTransaction, + createSaveTransactionState, + failSaveTransaction, +} = await import(moduleUrl); + +const oldCommit = { revision: 7, sha256: "a".repeat(64) }; +const newCommit = { revision: 8, sha256: "b".repeat(64) }; + +function stateAt(stage) { + let result = beginSaveTransaction(createSaveTransactionState(oldCommit), newCommit.revision); + assert.equal(result.ok, true); + for (const next of SAVE_ATTEMPT_STAGES.slice(1, SAVE_ATTEMPT_STAGES.indexOf(stage) + 1)) { + result = advanceSaveTransaction(result.state, next, next === "OPFS_STAGE" ? newCommit.sha256 : undefined); + assert.equal(result.ok, true); + } + return result.state; +} + +test("M7-05 preserves the old committed identity at every failed save stage", () => { + for (const stage of SAVE_ATTEMPT_STAGES) { + const failed = failSaveTransaction(stateAt(stage), `SAVE_${stage}_INTERRUPTED`); + assert.equal(failed.status, "FAILED"); + assert.equal(failed.stage, stage); + assert.deepEqual(failed.committed, oldCommit); + assert.equal(failed.candidate.revision, newCommit.revision); + } +}); + +test("M7-05 advances committed revision and hash only after matching metadata commit", () => { + const metadata = stateAt("METADATA_COMMIT"); + assert.deepEqual(commitSaveTransaction(metadata, { ...newCommit, sha256: "c".repeat(64) }), { + ok: false, + state: metadata, + errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH", + }); + const committed = commitSaveTransaction(metadata, newCommit); + assert.equal(committed.ok, true); + assert.deepEqual(committed.state.committed, newCommit); + assert.equal(committed.state.status, "SUCCEEDED"); +}); + +test("M7-05 rejects reentrant and out-of-order save transitions", () => { + const running = beginSaveTransaction(createSaveTransactionState(oldCommit), 8); + assert.equal(running.ok, true); + assert.equal(beginSaveTransaction(running.state, 9).errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION"); + assert.equal(advanceSaveTransaction(running.state, "SCENE_COMMIT").errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION"); +}); diff --git a/web/tests/unit/user-action-state.test.mjs b/web/tests/unit/user-action-state.test.mjs new file mode 100644 index 00000000..2cef058a --- /dev/null +++ b/web/tests/unit/user-action-state.test.mjs @@ -0,0 +1,93 @@ +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/user-action-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 { + USER_ACTION_KINDS, + createInitialUserActionStates, + reduceUserActionStates, + transitionUserAction, +} = await import(moduleUrl); + +function identity(kind, sequence = 1) { + return { kind, actionId: `${kind}:${sequence}` }; +} + +test("M7-01 initializes all five user actions at IDLE", () => { + const states = createInitialUserActionStates(); + assert.deepEqual(Object.keys(states), USER_ACTION_KINDS); + for (const kind of USER_ACTION_KINDS) { + assert.deepEqual(states[kind], { kind, status: "IDLE", identity: null, errorCode: null }); + } +}); + +test("M7-01 records deterministic RUNNING and SUCCEEDED transitions for every action", () => { + let states = createInitialUserActionStates(); + for (const kind of USER_ACTION_KINDS) { + const currentIdentity = identity(kind); + states = reduceUserActionStates(states, { type: "START", identity: currentIdentity }); + assert.deepEqual(states[kind], { kind, status: "RUNNING", identity: currentIdentity, errorCode: null }); + states = reduceUserActionStates(states, { type: "SUCCEED", identity: currentIdentity }); + assert.deepEqual(states[kind], { kind, status: "SUCCEEDED", identity: currentIdentity, errorCode: null }); + } +}); + +test("M7-01 records stable failure and cancellation codes", () => { + const failedIdentity = identity("OPEN"); + let failed = transitionUserAction(createInitialUserActionStates().OPEN, { type: "START", identity: failedIdentity }); + assert.equal(failed.ok, true); + failed = transitionUserAction(failed.state, { type: "FAIL", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" }); + assert.deepEqual(failed, { + ok: true, + state: { kind: "OPEN", status: "FAILED", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" }, + }); + + const cancelledIdentity = identity("IMPORT"); + let cancelled = transitionUserAction(createInitialUserActionStates().IMPORT, { type: "START", identity: cancelledIdentity }); + assert.equal(cancelled.ok, true); + cancelled = transitionUserAction(cancelled.state, { type: "CANCEL", identity: cancelledIdentity }); + assert.deepEqual(cancelled, { + ok: true, + state: { kind: "IMPORT", status: "CANCELLED", identity: cancelledIdentity, errorCode: "USER_ACTION_CANCELLED" }, + }); +}); + +test("M7-01 rejects wrong identities, reused identities and invalid transitions without mutation", () => { + const first = identity("SAVE"); + const other = identity("SAVE", 2); + const initial = createInitialUserActionStates().SAVE; + const beforeStart = transitionUserAction(initial, { type: "SUCCEED", identity: first }); + assert.deepEqual(beforeStart, { ok: false, state: initial, errorCode: "USER_ACTION_INVALID_TRANSITION" }); + + const running = transitionUserAction(initial, { type: "START", identity: first }); + assert.equal(running.ok, true); + assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: other, errorCode: "SAVE_FAILED" }), { + ok: false, + state: running.state, + errorCode: "USER_ACTION_IDENTITY_MISMATCH", + }); + assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: first, errorCode: "not-stable" }), { + ok: false, + state: running.state, + errorCode: "USER_ACTION_INVALID_ERROR_CODE", + }); + + const succeeded = transitionUserAction(running.state, { type: "SUCCEED", identity: first }); + assert.equal(succeeded.ok, true); + assert.deepEqual(transitionUserAction(succeeded.state, { type: "START", identity: first }), { + ok: false, + state: succeeded.state, + errorCode: "USER_ACTION_IDENTITY_REUSED", + }); +}); diff --git a/后续工作.txt b/后续工作.txt new file mode 100644 index 00000000..d3bdc981 --- /dev/null +++ b/后续工作.txt @@ -0,0 +1,216 @@ +Web Blender M6/M7 接续执行记录 +更新时间:2026-08-15(America/New_York) + +执行规则 +1. 本文件是当前接续入口;每轮先读本文件,再按“唯一下一领取点”直接执行。 +2. 严格按当前里程碑原子任务顺序推进,前一项未通过时不把后一项标为完成。 +3. 每完成一段,写回实际命令、结果、hash、当前计数和下一领取点。 +4. 不覆盖用户已有改动,不提交或改写 git 历史。 + +当前状态 +- M6 正式完成:71/71;剩余:0。 +- M7 正式完成:7/18;剩余:11。 +- 已完成到:M7-07。 +- 唯一下一领取点:M7-08(Worker 崩溃时展示可恢复错误,不清空当前项目列表)。 +- quick lane:READY,7/7 命令通过,总命令耗时 11,455 ms,报告 + release/ci-reports/quick.json。 +- Chromium lane:READY,9/9 命令通过;每个 browser server 使用独立动态端口,报告 + release/ci-reports/chromium.json,总命令耗时 41,003 ms。 +- release lane:READY,25/25 命令通过,总命令耗时 1,334,766 ms,报告 + release/ci-reports/release.json。 + +本轮新增并已验证 +- M6-09A-C:binary archive 解包到 mktemp 后由包内 deployment contract 启动;新浏览器 + context 阻断所有非 loopback 请求,manifest、WASM、CSS、3 个 Worker、字体和基础 UI 冷启动通过。 +- M6-10A-D:真实归档环境完成 OPFS open/edit/save、Worker 重建、离线恢复、继续 edit、 + undo/redo、revision 单调保存和 GLB 严格回读;stable object/data IDs 与 blend SHA-256 已核对。 +- M6-11A-C:binary archive 在独立目录校验 SHA256SUMS、合同、许可证、SBOM、manifest, + 23 个 manifest 文件通过;archive browser smoke 通过并清理 server/profile。 +- M6-12A-D:source archive 在另一个独立目录校验 18,173 个文件,无 pycache/dist/build 输出; + npm ci、typecheck、build 通过,14 个 production build 文件与 binary app 逐项一致。 +- M6-13A-C:确认 Gitea Actions、自托管 Debian 13 runner、Node 20.19.2、Playwright 1.62.1、 + Chromium 149/Chrome 150、Blender 5.2.0、Emscripten 3.1.69;quick cache 只绑定 lockfile/toolchain。 +- M6-14A-C:P0、main/Offscreen、network、device-loss、OOM、OPFS quota、malicious blend/archive + 共 9 个 Chromium lane 子命令通过,端口均动态分配并由 Playwright 清理。 +- M6-15A:full E2E 126/126、100k/1M/10M geometry、4K/8K texture、600 帧 simulation、 + 1M 帧 media、VDB native/server/OPFS/WebGPU/双视口/故障、49/49 acceptance、 + offline reproducibility 和 archive 复验共 19/19 通过。 +- full E2E 首次发现开发服务器错收集 archive-only spec;已将该 spec 限制到 + playwright.archive.config.ts,定向 M6-09 通过后完整 release lane 从头重跑通过。 +- M6-15B/C:release 仅由 main push 或 workflow_dispatch 触发;exit code 17 失败注入 + 只生成新 FAILED 报告,不复用旧 READY。 +- M6-16A-C:quick/Chromium/release 三报告联合校验通过,绑定同一 commit、 + lockfile、ledger、engine、archive 和每条日志 SHA-256,retention 固定为 7/14/30 天。 +- M6-17A:DEPLOYMENT.md 已补齐空目录安装和 transport preflight;自动门在 mktemp + 中校验交付 SHA-256、拒绝危险 tar 条目、安装到内容 hash 目录、原子建立 current, + 验证 HTTPS/127.0.0.1 READY 与 public HTTP/file BLOCKED,并从安装后目录验证合同头/range。 +- M6-17B:binary archive 新增 release-metadata.json,绑定 product/engine、IDB schema 6、 + OPFS schema 1、3 个 Worker 和缓存策略;双版本门验证 5->6 前向迁移、Worker 换代、 + 失败前置不改 current、成功原子切换、旧 release 保留和项目 SHA-256 不变。 +- M6-17C:回滚合同只原子切换 current 的整套 app/engine release;相同 IDB/OPFS schema + 旧版 READY,IDB 降级和 OPFS 逆迁移稳定 BLOCKED;新版目录、项目 bytes、revision 17 和 + blend SHA-256 全部保留。 +- M6-17D:operations-diagnostics.json 与 DEPLOYMENT.md 表格覆盖 MIME、range、isolation、 + hash、quota、Worker、GPU 七域,21 个信号唯一;检查、预期、恢复和数据安全边界 + 与 deployment contract 联合校验,诊断 JSON 已进入 binary archive。 +- M6-17E:operations rehearsal 在同一个全新 mktemp 根内连续执行 8/8 步骤: + delivery hash/path、空目录安装、deploy HTTP、项目写入、upgrade 切换/HTTP、rollback 切换/HTTP; + revision 23 与 blend SHA-256 不变,临时根已清理。 +- M6-18A:冻结 `0.1.0-rc.1`、engine `blender-wasm-0.1.0-rc.1` 和 HEAD + `17ab961485fe2ea1574c5f0b7d3ef9608a2bf424`;RC 生成器先稳定拒绝旧 `0.1.0` + metadata,再重建并两次复验确定性 binary/source archive。`RC_MANIFEST.json` 与 sidecar + 已绑定 package/package-lock、engine manifest、binary 内 release metadata/SBOM、ledger、 + `SHA256SUMS.txt`、3 个主要构件和 5 个 operations report;binary 25 文件、source 18,185 + 文件及 14 个重建产物独立复验通过。 +- M6-18B:新增 RC 发布说明,只列已通过的 P0、双 viewport、single/pthread、OPFS、 + 性能/故障、离线交付和 CI/运维能力;自动核对 12 个 V1 family `READY` 与 12 个 + Blender 全域 `BLOCKED`,7 个本地链接全部可解析。`V1_SCOPE.md` 和机器 ledger 已进入 + 离线包,binary manifest 复验 28 个文件通过。 +- M6-18C:已知限制明确 Chromium-only、HTTPS/loopback、AUTO/SINGLE/PTHREAD 选择、 + 初始化失败单次 fallback、hash fatal 和 release 刷新边界;记录 WASM 16 MiB 初始/2 GiB + ceiling、origin-bound storage/无最低 quota 保证、纹理 64 MiB/16,384 px/256 项,以及 + VDB/NanoVDB 512 MiB/1 GiB/512 MiB 上限与 adapter 可能更低。限制自动检查与 binary + 29 文件复验通过。 +- M6-18D:发布恢复手册覆盖 SHA256SUMS/RC manifest/归档/三 lane 证据校验、生产 + Save Project 的 OPFS commit + 外部 `.blend` 备份、revision/hash/origin 记录、切换前失败、 + 切换后兼容回滚、IDB/OPFS schema 稳定阻断及独立恢复 origin;文档检查、binary 30 文件 + 复验和全新临时根 operations rehearsal 8/8 再次通过。 +- M6-18E:quick 从首次 unit hardcoded version 的新 `FAILED` 报告定位并修正后完整重跑 + `READY 7/7`;Chromium `READY 9/9`;release 扩充 RC 文档、5 项 operations 和 manifest + 门后 `READY 25/25`。full E2E 126/126、49/49 acceptance、归档重建/浏览器/独立 source + rebuild 全部通过;三报告与 RC manifest 联合绑定同一 HEAD、package/lockfile、ledger、 + engine、SBOM、3 份发布文档和最终 archive hash,exit code 17 仍只生成新 `FAILED`。 +- M7-01:新增纯 `user-action-state` 合同,覆盖 IMPORT/OPEN/SAVE/SAVE_AS/EXPORT 和 + IDLE/RUNNING/SUCCEEDED/FAILED/CANCELLED;action identity、允许转换、身份/复用/错误码 + 拒绝均为确定性结果。生产 App 已在图片 Main transaction、blend open、OPFS commit、blend + download 和 GLB export 的真实边界记录状态;保存仍保持先原子提交再下载。unit 4/4、 + Chromium 正负例 2/2、typecheck 和 production build 全部通过。 +- M7-02:新增纯项目动作互斥合同,OPEN/SAVE/CLOSE 冲突矩阵完全对称且独占,冲突统一返回 + `USER_ACTION_CONFLICT`,并细分 REPEATED_OPEN、CONCURRENT_SAVE、CLOSE_DURING_SAVE。 + App 的 open、manual save、autosave 和 close 使用同一 owner lock;同一浏览器任务竞争验证第二次 + open 不替换首个 projectId、双 save 只提交/下载一次、save 中 close 不清空 scene 或终止 Worker, + 提交后继续 edit/save/close 正常。互斥 unit 4/4,联合 unit 8/8、新 Chromium 5/5、既有 + save/autosave 2/2、typecheck 和 build 全部通过。 +- M7-03:新增 `File.stream()` 分块 reader;每个实际 stream chunk 最多按 1 MiB 有界复制, + 进度来自累计真实字节/文件声明大小,每 4 MiB 让出一次 UI 并同步提交紧凑进度状态,不使用 + 时间插值。每次 open 有独立 AbortController,读入阶段提供取消按钮;取消稳定进入 + `CANCELLED/OPEN_CANCELLED`,不调用 WebEngine、不替换旧 scene/projectId。621,323-byte 有效 + blend 精确完成,40 MiB 合成输入在中间字节取消;reader unit 3/3,联合 unit 11/11、 + 新 Chromium 7/7、既有 save/autosave 2/2、typecheck 和 build 全部通过。 +- M7-04:reader 资源 ledger 在取消返回前执行 stream cancel/release、清零未完成 output,并证明 + live reader/input bytes/staging 全为 0。WebEngine open 改为隔离候选 WASM module/Main handle, + AbortSignal 通过 cancelOpen request 在 NATIVE_INITIALIZE/NATIVE_OPENED 阶段取消;只有构建 + SceneIR 成功才原子替换当前 Main,取消则释放 native input、销毁候选 handle,当前架构无 OPFS + import staging,计数恒为 0。native 取消后旧 Basic Main 继续 edit/save;联合 unit 11/11、 + 新 Chromium 8/8、既有 open/save/autosave/concurrent-open/isolated-preview 5/5、build 全部通过。 +- M7-05:新增纯 save transaction 合同,冻结 base committed revision/hash,SERIALIZE、OPFS_STAGE、 + SCENE_COMMIT、METADATA_COMMIT 任一失败均只更新 attempt;只有 Storage 返回的 revision/hash 与 + candidate 完全一致才推进 committed。已有基线项目在 after-stage、after-scene-commit、 + before-metadata-commit 故障中用同一原子 writer 回写旧 bytes 后抛错,Worker 重启后旧 + revision/hash/bytes 逐项不变;无基线 M6 journal 前滚仍通过。unit 14/14、新 Chromium 9/9、 + 旧 recovery/save/autosave/quota 4/4、build 全部通过。 +- M7-06:新增纯 dirty state,open/recover 建立 current/committed Main revision,只有成功 Main + transaction 推进 current,匹配 candidate 的成功 save 才对齐 committed;preview、workspace、 + 失败 import/open 保持原状态。删除手动 dirty toggle,旧 autosave 测试改用真实 Add Cube。 + unit 17/17、新 Chromium 10/10、真实 Main/save/autosave 3/3、build 全部通过。 +- M7-07:undo/redo 成功后序列化当前 Main 并与最近成功 save 的 blend SHA-256 比较;逻辑 + revision 始终单调增加,内容 hash 相同即 clean、不同即 dirty。save -> edit -> undo clean -> + redo dirty -> undo clean 的 Chromium 路径通过。联合 unit 18/18、Chromium 10/10、build 通过。 + +待完成任务(严格顺序) +- M7-08:Worker 崩溃时展示可恢复错误,不清空当前项目列表。 +- M7-09 至 M7-18:Worker/recent projects 恢复、 + 存储预算与清理、输入/响应式/双 viewport/键盘可访问性、诊断报告和 30 分钟 soak。 + +M6-15/M6-16 实际验证命令 +- BLENDER_BIN= BLENDER_ARCHIVE_SHA256=96f6c181...1c48 + CHROME_PATH= VDB_RESOURCE_ROOT= npm --prefix web run ci:release +- node tools/web/check-ci-report.mjs release/ci-reports/quick.json + release/ci-reports/chromium.json release/ci-reports/release.json +- npm --prefix web run test:ci-failure-report + +M7-01 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5194 npm --prefix web run test:user-actions`:unit 4/4、Chromium 2/2 通过。 +- `npm --prefix web run build`:通过,55 modules transformed。 +- `git diff --check -- web/protocol/user-action-state.ts web/tests/unit/user-action-state.test.mjs + web/tests/e2e/user-action-state.spec.ts web/app/src/app/App.tsx web/package.json`:通过。 +- `user-action-state.ts` SHA-256:232257fb0e85a6827d71a1acbbf82477ba8c441c224cf994049bde96943c518e。 +- unit/Chromium spec SHA-256:88ace5b65a5bee44d4ab417ea2144391d461117e848b1446da86b1cf0b21e237 / + 9ecb1e63f58c25dc64e0f8738b272b45529212469c9ea2ca660b68d4f0e7b566。 + +M7-02 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5196 npm --prefix web run test:user-actions`:联合 unit 8/8、Chromium 5/5 通过。 +- `WEB_TEST_PORT=5197 npx --prefix web playwright test --config web/playwright.config.ts + web/tests/e2e/smoke.spec.ts -g "saves the opened blend|autosaves a dirty project"`:2/2 通过。 +- `npm --prefix web run build`:通过,56 modules transformed。 +- `project-action-mutex.ts` SHA-256:330da0a7253f9bda9f7b43f5df4a252596fe43723c489308b1e0cbb6311aefd4。 +- unit/Chromium spec SHA-256:92af891ddbb54c54c2717a8e8d4466678de2cb7117f413f431a2e32fa2446f0c / + 0eb8f7c40dab623762d04e485348fbdaa68e9782010199e5d1b888f9475d6896。 + +M7-03 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5206 npm --prefix web run test:user-actions`:联合 unit 11/11、Chromium 7/7 通过。 +- `WEB_TEST_PORT=5207 npx playwright test --config web/playwright.config.ts web/tests/e2e/smoke.spec.ts + -g "saves the opened blend|autosaves a dirty project"`:2/2 通过。 +- `npm --prefix web run build`:通过,57 modules transformed。 +- `file-byte-reader.ts` SHA-256:3472e2a8cc01b6a78dda85d90a0a6f106ee3c0ad0e3c598bd3f6a7cbca14b536。 +- unit/Chromium spec SHA-256:1e86e6f553cafcd2a890eab76f090ba2eb249c21fced88b6f82db80162254bc8 / + 78608c721e5bd66570ae73b13c9c4531fc7d555b226c194188e73cddcde6b370。 + +M7-04 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5210 npm --prefix web run test:user-actions`:联合 unit 11/11、Chromium 8/8 通过。 +- `WEB_TEST_PORT=5211 npx playwright test --config web/playwright.config.ts web/tests/e2e/smoke.spec.ts + -g "opens a local .blend|serializes concurrent WebEngine init|previews Decimate|saves the opened blend|autosaves"`:5/5 通过。 +- `npm --prefix web run build`:通过,57 modules transformed。 +- WebEngineClient/Worker SHA-256:1e2cac9f9a9ebfb068cb8a7cfeab5100d9534600dcdd703a12d68614940a28e5 / + 331c7854ea75e2def1bf503118c670908d39ce9ede9c5deb5a4432812172582b。 +- Chromium spec SHA-256:467d50dadce0f2b1d8da090fcc9383c642d3fd9fa19d19b53833340d418490a6。 + +M7-05 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5214 npm --prefix web run test:user-actions`:联合 unit 14/14、Chromium 9/9 通过。 +- `WEB_TEST_PORT=5215 npx playwright test --config web/playwright.config.ts web/tests/e2e/smoke.spec.ts + -g "recovers verified atomic saves|quota exhaustion|saves the opened blend|autosaves"`:4/4 通过。 +- `npm --prefix web run build`:通过,58 modules transformed。 +- save transaction/StorageWorker SHA-256:9c6d932d6d2ce308ae8fbc56ee56296f1b67bd22e76b90a69751813f174bc766 / + bfde02b78850beb510c7a92fcefb6b3b9f5d2ef4bef09027a7fdc6f737181789。 +- unit/Chromium spec SHA-256:99fde5eb7152e1fe0df8702660724f8457a6423fb1d731f92758670b531cfd96 / + e453f817b3bae19ddf18c57c4429743e7157d083ab9ebf2e8e523bc6301d226d。 + +M7-06 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5217 npm --prefix web run test:user-actions`:联合 unit 17/17、Chromium 10/10 通过。 +- `WEB_TEST_PORT=5218 npx playwright test --config web/playwright.config.ts web/tests/e2e/smoke.spec.ts + -g "Main-authoritative|saves the opened blend|autosaves a dirty project"`:3/3 通过。 +- `npm --prefix web run build`:通过,59 modules transformed。 +- dirty contract/unit/Chromium SHA-256:283d08dd92e7f7afc13ba004982160d23369a42ea487c77c7fc6de803f4c00e7 / + ab71114adb58fec8387ff8b8ca0a1729681a57e370a995c0ee7e71eeabc3baf2 / + 9980065a202b97975d299ef93312c442f4bc69b8840e899e89b1f802d13967e7。 + +M7-07 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5220 npm --prefix web run test:user-actions`:联合 unit 18/18、Chromium 10/10 通过。 +- `npm --prefix web run build`:通过,59 modules transformed。 +- dirty contract/unit/Chromium SHA-256:ee1a04a6f43a4c5504657f318249c9c2e27abe47647cef64237b94ba2e56f1ea / + 1e5c6ddeb29ca110b90a01f40a8ceca26e18d7787455dd13aeb8a0da76adb602 / + 9b884c1e975b4ae4b55f6bc80ed191444790d75eccb09718863f24a54fd4aa18。 + +M7-08 立即执行 +- WebEngine/Storage Worker error 统一映射为 recoverable worker fault,不在 error callback 中清空 snapshot、 + recent project 或 committed/dirty identity;所有 pending request 只结算一次。 +- 提供显式“重启并恢复”入口,先重建 Worker,再从当前项目 OPFS commit + operation log 恢复。 +- 添加 Worker 崩溃注入,验证可恢复短错误、项目列表/当前 UI 保留和恢复成功;完成后推进 M7-09。 + +关键当前构件 +- binary archive SHA-256:8af6c782b8e4b31020219597b821160b7634439749c455fd31d9d919676e2e2e +- source archive SHA-256:ee09b3e43e715b3d5497573bbf63a449cbea146150d56c08c6506afd5fce2871 +- SBOM SHA-256:7d0fb22790c44bada5f01e5ebc12f6050e1c0436a53f58dceb2e979c17bb593a +- RC manifest SHA-256:862d332b3c931cb5c0898bb7f66e02f0dd4562053b18d0c87a7cea390ebaacdf +- M6-17E rehearsal report SHA-256:b953dd89165b5dd9260d75c7ec6e982a69cfeea358fbc2ce35c75e99544cac2c +- quick/Chromium/release report SHA-256:dcaa32be911fa7ed36127b5cb1458bb07dcca19d562cabc64e022f13b094f911 / + 90b19d057957f1edce264c7137f89d6701643e46feb88001054eea1bf0a18eb5 / + 7d28a5018b78ba45935f832388d6b4e632c6198476a2fdcb4a0abef3066c5ec6 +- HEAD commit:17ab961485fe2ea1574c5f0b7d3ef9608a2bf424