chore: checkpoint web Blender feature-gap work, docs and tests
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- update native web_engine sources and rebuilt wasm artifacts
- add task cards, status notes and tooling scripts
- add web e2e specs, tutorial UI sources and tutorial assets
- sync golden task manifests and audit records

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-11 10:46:55 +08:00
parent f3e21c6b98
commit d1e6b63d54
665 changed files with 59149 additions and 39194 deletions

View File

@@ -0,0 +1,430 @@
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const abs = (file) => path.join(root, file);
const read = (file) => fs.readFileSync(abs(file), "utf8");
const json = (file) => JSON.parse(read(file));
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
const writeJson = (file, value) => { fs.mkdirSync(path.dirname(abs(file)), { recursive: true }); fs.writeFileSync(abs(file), `${JSON.stringify(value, null, 2)}\n`); };
const writeText = (file, value) => { fs.mkdirSync(path.dirname(abs(file)), { recursive: true }); fs.writeFileSync(abs(file), value); };
const run = (entry, ...args) => execFileSync(process.execPath, [abs(entry), ...args], { cwd: root, stdio: "inherit" });
const sourcePlanPath = "tests/golden/WBV2-P1424-001/recovery-20261020/execution-plan.json";
const planPath = "tests/golden/WBV2-P1425-000/domain-parity-plan.json";
const parentTask = "WBV2-P1424-001";
const bridgeTask = "WBV2-P1424-900";
const rootTask = "WBV2-P1425-000";
const moduleTask = "WBV2-P1425-001";
const moduleId = "OBJECT_ADD_ROUTE";
const queuePath = "docs/EXECUTION_QUEUE.md";
const parentManifestPath = `tests/golden/${parentTask}/manifest.json`;
const bridgeManifestPath = `tests/golden/${bridgeTask}/manifest.json`;
const planDocPath = "docs/plans/CREATE_MODEL_HIERARCHY_PARITY_PLAN.md";
const preservedContinuation = "tests/golden/WBV2-P1424-001/continuation-20261103-goal-numpy.json";
// Repair the partial first run if it touched the preserved task head; the original
// task must keep its in_progress status and null nextTask byte-for-byte.
const parentManifest = read(parentManifestPath);
const polluted = ` "status": "in_progress",\n "enablingTask": true,\n "nextTask": "${rootTask}",\n "supersession": [^\n]*,\n`;
const pristine = ` "status": "in_progress",\n "nextTask": null,\n`;
const restored = parentManifest.replace(new RegExp(polluted, "u"), pristine);
assert.ok(!restored.includes(`"nextTask": "${rootTask}"`), "failed to restore the preserved P1424-001 manifest");
if (restored !== parentManifest) writeText(parentManifestPath, restored);
assert.equal(fs.statSync(abs(parentManifestPath)).size, 212423, "P1424-001 manifest size changed; refuse to continue");
const inventory = json("tests/golden/WBV2/dynamic-inventory.json");
const createIdentifiers = new Set([
"OBJECT_OT_add", "OBJECT_OT_add_named", "OBJECT_OT_camera_add", "OBJECT_OT_light_add",
"OBJECT_OT_empty_add", "OBJECT_OT_armature_add",
]);
const coverageBindings = inventory.units
.filter((unit) => {
const id = unit.identifier ?? "";
return /^MESH_OT_primitive_(circle|cone|cube|cylinder|grid|ico_sphere|monkey|plane|uv_sphere)_add$/u.test(id)
|| createIdentifiers.has(id);
})
.map((unit) => unit.id);
assert.ok(coverageBindings.length >= 14, `create coverage bindings too small: ${coverageBindings.length}`);
const moduleContract = {
moduleId,
sourceBacked: true,
capabilities: [
"CAP-OBJECT-ADD-MENU",
"CAP-MESH-PRIMITIVE-ADD",
"CAP-MESH-PRIMITIVE-PARAMETERS",
"CAP-OPERATOR-CHANNEL-NATIVE-CONTEXT",
],
sourcePaths: [
"blender-5.2.0/source/blender/web_engine/web_engine_python_bootstrap.py",
"blender-5.2.0/source/blender/web_engine/web_engine_gpu_ui.cpp",
"blender-5.2.0/source/blender/web_engine/web_engine_api.cpp",
"web/app/src/app/App.tsx",
"web/app/src/engine-client/WebEngineClient.ts",
"web/protocol/web-engine.ts",
"web/app/src/capabilities/blender-feature-route-catalog.json",
],
reuseReceipts: [
{ manifest: "tests/golden/WBV2-P18-002/manifest.json", report: "tests/golden/WBV2-P18-002/object-create-production.json" },
],
positive: [
"Add Circle/Grid/Monkey and existing mesh primitives through the native operator channel",
"Create camera/light/empty/armature through the same Add menu route",
"location/rotation/scale/align/enter_editmode are applied by the native operator",
],
negative: [
"unsupported primitive identifier fails with a structured error and no Main mutation",
"stale baseRevision and wrong mode/area fail closed without an undo entry",
],
recovery: ["undo", "redo", "save/reopen", "worker restart"],
commands: [
"source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8",
"node tools/web/sync-web-engine-runtime.mjs",
],
coverageBindings,
fullFamilyParity: false,
};
const bridgeRecord = {
id: bridgeTask,
phase: "1424",
title: "supersede_stalled_task_for_domain_parity",
kind: "bridge",
owner: "PROGRAM",
dependsOn: [parentTask],
parityId: null,
implementationClass: "QUEUE_ONLY",
parallelGroup: "domain-parity-bridge",
gate: "QUEUE",
card: `docs/tasks/${bridgeTask}.md`,
behavior: "Record the user-authorized supersession of stalled WBV2-P1424-001 and authorize the P1425 domain-parity epoch without modifying the preserved task manifest.",
outputs: ["supersession record", "bridge manifest", "status"],
acceptance: ["P1424-001 manifest and continuation remain preserved", "bridge manifest points to P1425-000"],
commands: ["node tools/web/check-execution-control-plane.mjs"],
status: "done",
ready: false,
queueMutationAllowed: true,
supersedes: parentTask,
};
const rootRecord = {
id: rootTask,
phase: "1425",
title: "authorize_create_model_hierarchy_parity_epoch",
kind: "bridge",
owner: "PROGRAM",
dependsOn: [bridgeTask],
parityId: null,
implementationClass: "QUEUE_ONLY",
parallelGroup: "domain-parity-root-1425",
gate: "QUEUE",
card: `docs/tasks/${rootTask}.md`,
behavior: "Authorize the create/modeling/hierarchy full-parity epoch while preserving the stalled P1424-001 task record.",
outputs: ["domain parity plan", "successor authorization", "manifest", "status"],
acceptance: [
"P1424-001 manifest and continuation remain intact and in_progress",
"plan/index/queue pointer are consistent",
"one source-backed module is active",
],
commands: ["node tools/web/check-execution-control-plane.mjs"],
status: "done",
ready: false,
queueMutationAllowed: true,
supersedes: parentTask,
userAuthorization: {
approvedAt: "2026-11-10",
scope: "创建模型 / 模型化 / 模型层级 三域全量对标桌面 Blender",
planDocument: planDocPath,
decision: "授权桥接并保留 P1424-001 记录",
},
};
const moduleRecord = {
id: moduleTask,
phase: "1425",
title: "implement_object_add_route",
kind: "module",
owner: "OBJECT",
dependsOn: [rootTask],
parityId: moduleId,
implementationClass: "LOCAL_EXACT",
parallelGroup: "module-object_add_route",
gate: "MODULE",
card: `docs/tasks/${moduleTask}.md`,
behavior: "Route the Web Add menu through the native desktop operator channel for mesh primitives and basic object helpers.",
outputs: ["module contract", "Add menu wiring", "positive/negative/recovery cases", "manifest", "status"],
acceptance: [
"every bound operator unit has a native Main mutation case",
"unsupported/stale/wrong-context cases fail closed",
"undo/redo and save/reopen preserve identical semantics",
"full-family parity remains explicit",
],
commands: moduleContract.commands,
status: "in_progress",
ready: true,
queueMutationAllowed: true,
module: moduleContract,
};
const plan = fs.existsSync(abs(planPath)) ? json(planPath) : JSON.parse(JSON.stringify(json(sourcePlanPath)));
plan.planId = "BLENDER_WEB_CREATE_MODEL_HIERARCHY_PARITY_P1425";
plan.status = "ACTIVE_MODULE_EXECUTION_QUEUE";
plan.authority = {
...(plan.authority ?? {}),
userAuthorization: {
approvedAt: "2026-11-10",
scope: "创建模型 / 模型化 / 模型层级 三域全量对标桌面 Blender",
planDocument: planDocPath,
supersedes: parentTask,
preservedTask: parentTask,
bridge: bridgeTask,
},
};
plan.tasks = plan.tasks.filter((task) => ![bridgeTask, rootTask, moduleTask].includes(task.id));
plan.modules = plan.modules.filter((module) => module.id !== moduleId);
const stalled = plan.tasks.find((task) => task.id === parentTask);
assert.ok(stalled, `${parentTask} record is missing`);
stalled.status = "blocked";
stalled.ready = false;
stalled.supersededBy = bridgeTask;
stalled.supersessionReason = "User approved the three-domain full-parity plan on 2026-11-10; P1424-001 manifest, continuation and acceptance state are preserved.";
stalled.preservedContinuation = preservedContinuation;
plan.tasks.push(bridgeRecord, rootRecord, moduleRecord);
plan.modules.push({
id: moduleId,
task: moduleTask,
owner: "OBJECT",
sourceBacked: true,
capabilities: moduleContract.capabilities,
reusedReceipts: moduleContract.reuseReceipts,
fullFamilyParity: false,
});
writeJson(planPath, plan);
writeText(planDocPath, fs.readFileSync(path.join(process.env.HOME ?? "", ".claude/plans/cosmic-greeting-marble.md"), "utf8"));
writeText(`docs/tasks/${bridgeTask}.md`, `# ${bridgeTask}: supersede stalled task for domain parity
- task: ${bridgeTask}
- parent: ${parentTask}
- status: done
- ownerFamily: PROGRAM
- targetImplementationClass: QUEUE_ONLY
## 目标
记录用户 2026-11-10 授权的队列桥接:保留 \`${parentTask}\` 的 manifest、continuation、\`in_progress\` 状态和全部未闭合证据,不修改该文件;由本桥接指向三域全量对标 epoch 根任务 \`${rootTask}\`。
## 输入与范围
- 保留任务:\`${parentManifestPath}\`(期望 212423 bytes,原样)
- 保留 continuation:\`${preservedContinuation}\`
- 新计划:\`${planPath}\`
## 验收
- P1424-001 manifest 字节级不变;桥接 manifest 为 done 且 nextTask 指向 \`${rootTask}\`。
- 负例:P1424-001 manifest 大小变化、continuation 缺失或队列已被再次切换时拒绝继续。
- 校验:\`node tools/web/check-execution-control-plane.mjs\`
## 交付与回滚
- 交付桥接 manifest/status/report;回滚把队列恢复为 \`${parentTask}\` 并删除桥接指针,不删除任何证据。
`);
writeText(`docs/tasks/${rootTask}.md`, `# ${rootTask}: authorize create/modeling/hierarchy parity epoch
- task: ${rootTask}
- parent: ${bridgeTask}
- status: done
- ownerFamily: PROGRAM
- targetImplementationClass: QUEUE_ONLY
- Implementation entrypoint: \`node tools/web/authorize-wbv2-p1425.mjs\`
## 目标
以用户 2026-11-10 批准的三域全量对标计划为依据,授权新的 module epoch,并把队列指向首个模块 \`${moduleTask}\`。\`${parentTask}\` 的 manifest、continuation、in_progress 状态和全部未闭合证据原样保留,不删除、不伪装完成。
## 输入与范围
- 用户批准计划:\`${planDocPath}\`
- 源队列计划:\`${sourcePlanPath}\`
- 新计划:\`${planPath}\`
- 保留任务:\`${parentTask}\`(\`${parentManifestPath}\`)
- 桥接 manifest:\`${bridgeManifestPath}\`
## 验收
- P1424-001 manifest 仍为 \`in_progress\` 且字节级未变。
- 新计划中恰好一个 ready/in_progress 模块;索引 activeTask 与队列指针一致。
- 负例:P1424-001 缺失、重复授权或索引不一致时脚本必须拒绝对队列做第二次切换。
- \`node tools/web/check-execution-control-plane.mjs\`
## 交付与回滚
- 交付 plan、manifest、status、report、task-context;校验 \`node tools/web/check-execution-control-plane.mjs\`。
- 回滚:恢复队列指针为 \`${parentTask}\`;新计划文件保留但不得作为 active 队列。
`);
writeText(`docs/tasks/${moduleTask}.md`, `# ${moduleTask}: implement Add menu native operator route
- task: ${moduleTask}
- parent: ${rootTask}
- status: in_progress
- gap: workflow:object_add_route
- ownerFamily: OBJECT
- targetImplementationClass: LOCAL_EXACT
- moduleId: ${moduleId}
- Implementation entrypoint: \`node tools/web/run-wbv2-module-task.mjs --task ${moduleTask} --write\`
## 目标
让 WASM 本地模式的 Add 菜单通过现有通用桌面 operator 通道(\`desktop_operator_catalog/schema/desktop_operator\` + \`gpuUIInvoke\`)创建 Mesh 全部基础体,包括缺失的 Circle/Grid/Monkey,并支持 location/rotation/scale/align/enter_editmode 参数;复用 Operator Palette、\`WebEngineClient\` 与原生 Main,不新增并行执行器。
## 输入与范围
- 原生通道:\`blender-5.2.0/source/blender/web_engine/web_engine_python_bootstrap.py\`、\`web_engine_gpu_ui.cpp\`、\`web_engine_api.cpp\`
- 前端:\`web/app/src/app/App.tsx\`(Add 菜单、F3、快捷条)、\`web/app/src/engine-client/WebEngineClient.ts\`
- 协议/路由:\`web/protocol/web-engine.ts\`、\`web/app/src/capabilities/blender-feature-route-catalog.json\`
- 绑定的源码单元:${coverageBindings.join(", ")}
- 已复用证据:\`tests/golden/WBV2-P18-002/manifest.json\`、\`tests/golden/WBV2-P18-002/object-create-production.json\`
## 失败边界
不支持的基础体/参数、错误 mode/area、陈旧 baseRevision、modal 占用必须返回结构化错误,Main revision 不变且不产生 undo 记录;linked/只读对象不得被创建路径绕过。
## 验收
- 正例:Add Circle/Grid/Monkey 及既有基础体经原生通道创建,Main mutation 与 snapshot 反映对象。
- 负例:非法标识、非法参数、陈旧 revision、错误上下文均 no-mutation。
- 恢复:undo/redo、save/reopen、Worker restart 语义一致。
- 命令:实现期只做增量编译与 runtime 同步;全部功能完成后由用户在 Chromium 验证。结果标 \`NOT_CLAIMED\`。
- \`cmake --build build_web_blender6 --target web_engine -- -j8\`
- \`node tools/web/sync-web-engine-runtime.mjs\`
## 交付与回滚
- 交付 module receipt、manifest、status、continuation;失败保持 \`in_progress\`,保留已有构件与证据。
`);
const reportPath = `tests/golden/${rootTask}/successor-authorization-report.json`;
writeJson(`tests/golden/${bridgeTask}/supersession-report.json`, {
schemaVersion: 1,
task: bridgeTask,
operation: "SUPERSEDE_STALLED_TASK_FOR_DOMAIN_PARITY",
status: "PASS",
preserved: {
task: parentTask,
manifest: parentManifestPath,
manifestBytes: fs.statSync(abs(parentManifestPath)).size,
manifestSha256: sha(parentManifestPath),
status: "in_progress",
continuation: preservedContinuation,
continuationSha256: sha(preservedContinuation),
acceptance: "NOT_CLAIMED",
},
userAuthorization: rootRecord.userAuthorization,
successor: { task: rootTask, plan: planPath },
nextTask: rootTask,
});
writeText(`docs/status/${bridgeTask}.md`, `# ${bridgeTask} status
- task: \`${bridgeTask}\`; parent: \`${parentTask}\`; status: \`done\`
- operation: \`SUPERSEDE_STALLED_TASK_FOR_DOMAIN_PARITY\`
- preserved: \`${parentTask}\` manifest \`${fs.statSync(abs(parentManifestPath)).size}\` bytes, status \`in_progress\`, continuance \`${preservedContinuation}\`
- successor: \`${rootTask}\`; plan: \`${planPath}\`
- nextTask: \`${rootTask}\`
`);
writeJson(bridgeManifestPath, {
schemaVersion: 1,
task: bridgeTask,
parentTask,
operation: "SUPERSEDE_STALLED_TASK_FOR_DOMAIN_PARITY",
status: "done",
enablingTask: true,
parityStateChange: false,
artifacts: {
preservedManifest: { path: parentManifestPath, sha256: sha(parentManifestPath) },
preservedContinuation: { path: preservedContinuation, sha256: sha(preservedContinuation) },
report: { path: `tests/golden/${bridgeTask}/supersession-report.json`, sha256: sha(`tests/golden/${bridgeTask}/supersession-report.json`) },
status: { path: `docs/status/${bridgeTask}.md`, sha256: sha(`docs/status/${bridgeTask}.md`) },
},
assertions: {
preservedTaskStatus: "in_progress",
preservedAcceptance: "NOT_CLAIMED",
preservedManifestBytes: fs.statSync(abs(parentManifestPath)).size,
supersedes: parentTask,
userApprovedAt: rootRecord.userAuthorization.approvedAt,
},
nextTask: rootTask,
});
writeJson(reportPath, {
schemaVersion: 1,
task: rootTask,
operation: "AUTHORIZE_DOMAIN_PARITY_EPOCH",
status: "PASS",
parent: { task: bridgeTask, manifest: bridgeManifestPath },
preserved: { task: parentTask, manifest: parentManifestPath, status: "in_progress", continuation: preservedContinuation, acceptance: "NOT_CLAIMED" },
successor: { task: moduleTask, moduleId, sourceBacked: true, coverageBindings: coverageBindings.length },
plan: { path: planPath, sha256: sha(planPath) },
userAuthorization: rootRecord.userAuthorization,
nextTask: moduleTask,
});
writeText(`docs/status/${rootTask}.md`, `# ${rootTask} status
- task: \`${rootTask}\`; parent: \`${bridgeTask}\`; status: \`done\`
- operation: \`AUTHORIZE_DOMAIN_PARITY_EPOCH\`; module: \`${moduleId}\`
- preserved: \`${parentTask}\` manifest remains \`in_progress\` (\`${fs.statSync(abs(parentManifestPath)).size}\` bytes); continuation \`${preservedContinuation}\`; acceptance \`NOT_CLAIMED\`
- new plan: \`${planPath}\`; bound source units: \`${coverageBindings.length}\`
- nextTask: \`${moduleTask}\`
`);
run("tools/web/generate-v2-task-index.mjs", "--plan-path", abs(planPath));
let queue = read(queuePath);
queue = queue
.replace(/\| 当前任务 \| `[^`]+` \|/u, `| 当前任务 | \`${moduleTask}\` |`)
.replace(/\| 里程碑 \| [^|]+ \|/u, `| 里程碑 | 三域全量对标(创建模型 / 模型化 / 模型层级) |`)
.replace(/\| parent manifest \| `[^`]+` \|/u, `| parent manifest | \`tests/golden/${rootTask}/manifest.json\` |`)
.replace(/\| 任务卡 \| \[[^\]]+\]\([^\)]+\) \|/u, `| 任务卡 | [\`tasks/${moduleTask}.md\`](tasks/${moduleTask}.md) |`);
writeText(queuePath, queue);
run("tools/web/check-task-context.mjs", "--task", rootTask, "--write");
writeJson(`tests/golden/${rootTask}/manifest.json`, {
schemaVersion: 1,
task: rootTask,
parentTask: bridgeTask,
operation: "AUTHORIZE_DOMAIN_PARITY_EPOCH",
status: "done",
enablingTask: true,
parityStateChange: false,
artifacts: {
parentManifest: { path: bridgeManifestPath, sha256: sha(bridgeManifestPath) },
plan: { path: planPath, sha256: sha(planPath) },
report: { path: reportPath, sha256: sha(reportPath) },
status: { path: `docs/status/${rootTask}.md`, sha256: sha(`docs/status/${rootTask}.md`) },
taskContext: { path: `tests/golden/${rootTask}/task-context.json`, sha256: sha(`tests/golden/${rootTask}/task-context.json`) },
},
assertions: {
supersedes: parentTask,
bridge: bridgeTask,
preservedTaskStatus: "in_progress",
preservedManifestBytes: fs.statSync(abs(parentManifestPath)).size,
preservedContinuation,
userApprovedAt: rootRecord.userAuthorization.approvedAt,
moduleId,
coverageBindings: coverageBindings.length,
sourceBackedSuccessor: true,
fullFamilyParity: false,
},
nextTask: moduleTask,
});
run("tools/web/check-task-context.mjs", "--task", moduleTask, "--write");
run("tools/web/check-execution-control-plane.mjs");
process.stdout.write(`wbv2-domain-parity-authorized bridge=${bridgeTask} root=${rootTask} next=${moduleTask} module=${moduleId} preserved=${parentTask}\n`);

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env node
/**
* Capture the illustrated parts tutorial from a headed Chromium browser.
*
* The script drives the real product entry (landing -> tutorial editor), runs
* every step's "演示本步" through the native Main, and writes screenshots plus a
* run report. By default it prefers the production build in web/dist; pass
* --dev to use the Vite dev server while iterating.
*
* Usage:
* node tools/web/capture-parts-tutorial-screenshots.mjs [--dev] [--port 5176]
*/
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const webDir = path.join(repoRoot, "web");
const requireFromWeb = createRequire(path.join(webDir, "package.json"));
const { chromium } = requireFromWeb("playwright");
const args = process.argv.slice(2);
const useDev = args.includes("--dev");
const landingOnly = args.includes("--landing-only");
const portFlag = args.indexOf("--port");
const port = Number.parseInt(portFlag >= 0 ? args[portFlag + 1] : process.env.WEB_TEST_PORT ?? "5176", 10) || 5176;
const baseURL = `http://127.0.0.1:${port}`;
const COURSES = {
parts: {
route: "/?tutorial=parts",
assets: path.join(webDir, "app/public/tutorial/parts"),
report: path.join(repoRoot, "tests/golden/PARTS_TUTORIAL-001"),
frameAll: ["assembly-hierarchy", "materials", "save-export", "summary"],
reopen: "展开零件建模教程",
},
reducer: {
route: "/?tutorial=reducer",
assets: path.join(webDir, "app/public/tutorial/reducer"),
report: path.join(repoRoot, "tests/golden/REDUCER_TUTORIAL-001"),
frameAll: ["shaft-assemblies", "root-hierarchy", "collections", "materials-assign", "save-export", "summary"],
reopen: "展开二级减速器教程",
},
};
const courseFlag = args.indexOf("--course");
const courseId = courseFlag >= 0 ? args[courseFlag + 1] : "parts";
const course = COURSES[courseId];
if (!course) throw new Error(`Unknown tutorial course: ${courseId}`);
const outDir = path.join(course.assets, "steps");
const reportDir = course.report;
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(reportDir, { recursive: true });
function launchServer() {
if (useDev) {
const vite = path.join(webDir, "node_modules/vite/bin/vite.js");
return spawn(process.execPath, [vite, "--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"], env: process.env,
});
}
const index = path.join(webDir, "dist/index.html");
if (!fs.existsSync(index)) throw new Error("web/dist/index.html is missing; run `npm run build` or pass --dev");
return spawn(process.execPath, [path.join(repoRoot, "tools/web/deployment-http-server.mjs"), "dist"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) },
});
}
async function waitForServer(timeoutMs = 60_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${baseURL}/`, { signal: AbortSignal.timeout(2000) });
if (response.ok) return;
} catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(`server did not become ready at ${baseURL}`);
}
function chromePath() {
return process.env.CHROME_PATH || "/usr/bin/google-chrome";
}
async function main() {
const server = launchServer();
let browser;
const report = { schemaVersion: 1, baseURL, startedAt: new Date().toISOString(), steps: [], errors: [] };
try {
await waitForServer();
browser = await chromium.launch({
headless: false,
executablePath: chromePath(),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await context.newPage();
page.on("pageerror", (error) => report.errors.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() !== "error") return;
// The editor's existing inline-style CSP violation is unrelated to the
// tutorial and appears on every page load; keep it out of the step verdict.
if (message.text().includes("Content Security Policy directive") && message.text().includes("style")) return;
report.errors.push(`console: ${message.text()}`);
});
await page.goto(`${baseURL}${course.route}`);
await page.getByRole("heading", { name: /从零制作/ }).waitFor({ timeout: 60_000 });
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.all(images.map((image) => image.complete ? Promise.resolve() : new Promise((resolve) => { image.addEventListener("load", resolve, { once: true }); image.addEventListener("error", resolve, { once: true }); })));
});
await page.screenshot({ path: path.join(course.assets, "landing.jpg"), fullPage: true, type: "jpeg", quality: 85 });
if (landingOnly) { report.status = "PASS"; return; }
await page.getByRole("button", { name: /开始学习/ }).click();
await page.locator("[data-tutorial-step]").waitFor({ timeout: 90_000 });
await page.locator('.tutorial-loading[data-tutorial-ready="false"]').waitFor({ state: "detached", timeout: 120_000 });
let stepIndex = 0;
for (;;) {
const panel = page.locator("[data-tutorial-step]");
const stepId = await panel.getAttribute("data-tutorial-step");
stepIndex += 1;
const fileName = `${stepId}.jpg`;
const demo = page.getByRole("button", { name: "演示本步" });
let demoState = "none";
if (await demo.count()) {
try {
await demo.click({ timeout: 45_000 });
} catch (error) {
const state = await demo.getAttribute("data-demo-state").catch(() => null);
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
report.errors.push(`${stepId}: demo button not actionable (${state ?? "unknown"}); engine=${engineStatus ?? "unknown"}`);
throw error;
}
demoState = "clicked";
await page.waitForFunction(() => {
if (document.querySelector(".tutorial-error")) return "error";
const rows = [...document.querySelectorAll("[data-check-id]")];
const demo = document.querySelector(".tutorial-demo");
const running = demo?.getAttribute("data-demo-state") === "running";
return !running && rows.length > 0 && rows.every((row) => row.getAttribute("data-ok") === "true") ? "ok" : false;
}, undefined, { timeout: 180_000 }).then((outcome) => {
if (outcome === "error") throw new Error("demo reported an error");
}).catch(async (error) => {
const overlay = (await page.locator(".tutorial-error").allTextContents()).join(" ") || "checks did not pass";
const checks = await page.locator("[data-check-id]").evaluateAll((rows) => rows.map((row) => ({
id: row.getAttribute("data-check-id"),
ok: row.getAttribute("data-ok"),
text: (row.textContent ?? "").trim().slice(0, 200),
})));
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
const actions = await page.evaluate(() => {
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
save: main?.getAttribute("data-user-action-save-status"), saveAs: main?.getAttribute("data-user-action-save-as-status"),
saveAsError: main?.getAttribute("data-user-action-save-as-error"),
export: main?.getAttribute("data-user-action-export-status"), exportError: main?.getAttribute("data-user-action-export-error"),
demo: demo?.getAttribute("data-demo-state"), demoText: demo?.textContent?.trim(),
};
});
await page.screenshot({ path: path.join(reportDir, `failure-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`${stepId}: ${overlay} :: ${JSON.stringify({ checks, engineStatus, actions })}`);
demoState = "failed";
throw error;
});
} else {
// Guide/summary cards only wait for a ready engine.
await page.waitForFunction(() => !document.querySelector('.tutorial-loading[data-tutorial-ready="false"]'), undefined, { timeout: 120_000 });
}
await page.waitForTimeout(500);
// Capture the clean viewport reference first so the full screenshot below
// already shows the step's reference image instead of a pending request.
const collapse = page.getByRole("button", { name: "收起教程侧栏" });
if (await collapse.count()) {
await collapse.click();
// Frame the step's subject. Steps whose active object is the assembly
// Empty use the whole scene instead of framing an empty point.
const frameAll = course.frameAll.includes(stepId);
await page.getByRole("button", { name: frameAll ? "显示全部" : "框显所选" }).click({ timeout: 5000 }).catch(() => undefined);
await page.waitForTimeout(400);
await page.locator(".viewport-placeholder").screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), type: "jpeg", quality: 85 }).catch(() => undefined);
await page.getByRole("button", { name: course.reopen }).click();
await page.waitForTimeout(600);
}
await page.screenshot({ path: path.join(outDir, fileName), fullPage: false, type: "jpeg", quality: 85 });
report.steps.push({ index: stepIndex, stepId, fileName, demo: demoState });
const next = page.getByRole("button", { name: /下一步|下一节/ });
if (!(await next.count())) break;
try {
await next.click({ timeout: 60_000 });
} catch (error) {
const state = await page.evaluate(() => {
const panel = document.querySelector("[data-tutorial-step]");
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
step: panel?.getAttribute("data-tutorial-step"),
project: panel?.getAttribute("data-tutorial-project"),
ready: panel?.getAttribute("data-tutorial-ready"),
demo: demo?.getAttribute("data-demo-state"),
save: main?.getAttribute("data-user-action-save-status"),
checks: [...document.querySelectorAll("[data-check-id]")].map((row) => ({ id: row.getAttribute("data-check-id"), ok: row.getAttribute("data-ok") })),
error: document.querySelector(".tutorial-error")?.textContent,
};
}).catch(() => null);
await page.screenshot({ path: path.join(reportDir, `failure-next-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`next-click ${stepId}: ${JSON.stringify(state)}`);
throw error;
}
await page.waitForTimeout(300);
}
await page.screenshot({ path: path.join(outDir, "00-assembly.jpg"), fullPage: false, type: "jpeg", quality: 85 });
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
} catch (error) {
report.status = "FAILED";
report.errors.push(error instanceof Error ? error.stack ?? error.message : String(error));
} finally {
await browser?.close().catch(() => undefined);
server.kill("SIGTERM");
fs.writeFileSync(path.join(reportDir, `headed-screenshots-${Date.now()}.json`), JSON.stringify(report, null, 2));
const latest = path.join(reportDir, "headed-screenshots-latest.json");
fs.writeFileSync(latest, JSON.stringify(report, null, 2));
console.log(JSON.stringify({ status: report.status, steps: report.steps.length, errors: report.errors.slice(0, 4) }, null, 2));
process.exitCode = report.status === "PASS" ? 0 : 1;
}
}
await main();

View File

@@ -18,10 +18,14 @@ const directives = new Map(policy.split(";").map((directive) => {
const tokens = directive.trim().split(/\s+/u);
return [tokens.shift(), tokens];
}));
const sourceTokens = [...directives.values()].flat();
const scriptDirectives = ["default-src", "script-src", "script-src-elem", "script-src-attr", "worker-src"];
const sourceTokens = scriptDirectives.flatMap((name) => directives.get(name) ?? []);
for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "*", "blob:"]) {
assert.equal(sourceTokens.includes(forbidden), false, `CSP contains forbidden token ${forbidden}`);
assert.equal(sourceTokens.includes(forbidden), false, `CSP script/worker directives contain forbidden token ${forbidden}`);
}
/* React Flow positions nodes/edges with inline style attributes; scripts stay strict. */
assert.deepEqual(directives.get("style-src-attr"), ["'unsafe-inline'"], "CSP must allow only inline style attributes for React Flow");
assert.deepEqual(directives.get("style-src"), ["'self'"], "CSP style elements must remain same-origin only");
for (const required of ["default-src 'self'", "script-src 'self'", "worker-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"]) assert.ok(policy.includes(required), `CSP is missing ${required}`);
const index = fs.readFileSync(path.join(root, "web/app/index.html"), "utf8");
assert.doesNotMatch(index, /<script\b[^>]*(?:\b(?:src\s*=\s*["']data:|type\s*=\s*["']text\/javascript["']))/iu);

View File

@@ -7,6 +7,11 @@ const readJson = (file) => JSON.parse(fs.readFileSync(path.join(root, file), "ut
const inventory = readJson("tests/golden/WBV2/dynamic-inventory.json");
const bindings = readJson("tests/golden/WBV2/dynamic-coverage-bindings.json");
const verified = new Set(bindings.filter((entry) => entry.status === "VERIFIED").map((entry) => entry.unitId));
const implementationPath = "tests/golden/WBV2-P1425-001/implemented-unit-bindings.json";
const implementation = fs.existsSync(path.join(root, implementationPath))
? readJson(implementationPath)
: { bindings: [] };
const implemented = new Map(implementation.bindings.map((entry) => [entry.unitId, entry]));
const browserPrefixes = new Set(["WM", "SCREEN", "FILE", "VIEW2D", "UI", "OUTLINER", "CONSOLE", "INFO", "PREFERENCES", "TEXT", "BUTTONS", "SPREADSHEET", "WORKSPACE", "GIZMOGROUP", "VIEW3D"]);
const browserActionGroups = {
OPEN: { command: "openBlendFile", description: "Browser file picker and native Main open workflow" },
@@ -33,6 +38,10 @@ function classify(unit) {
? ["BROWSER_EQUIVALENT", "non-runtime modifier sentinel or desktop UI helper"]
: ["WEB_MAIN", "metadata-driven native modifier stack route"];
}
if (implemented.has(unit.id)) {
const entry = implemented.get(unit.id);
return ["WEB_MAIN", `implemented Web operator route (${entry.implementation}): ${entry.uiRoute}`];
}
if (verified.has(unit.id)) return ["WEB_MAIN", "existing exact native Main binding"];
const operatorPrefix = prefix(unit.identifier);
if (operatorPrefix === "WM" && /_XR_/u.test(unit.identifier)) return ["UNAVAILABLE", "XR window-manager context is outside the Web runtime"];

View File

@@ -0,0 +1,76 @@
/* QA-only Dedicated Worker: exercises OPFS createSyncAccessHandle modes. */
const handles = new Map();
function reply(requestId, payload) {
self.postMessage({ requestId, ...payload });
}
async function resolveFile(path) {
const segments = String(path).split("/").filter(Boolean);
if (!segments.length) throw new Error("empty OPFS path");
let directory = await navigator.storage.getDirectory();
for (const segment of segments.slice(0, -1)) {
directory = await directory.getDirectoryHandle(segment, { create: true });
}
return directory.getFileHandle(segments[segments.length - 1], { create: true });
}
self.onmessage = async (event) => {
const data = event.data ?? {};
const requestId = data.requestId ?? "";
try {
if (data.op === "capability") {
reply(requestId, {
ok: true,
hasSyncMethod: typeof FileSystemFileHandle !== "undefined" &&
typeof FileSystemFileHandle.prototype.createSyncAccessHandle === "function",
isDedicatedWorker: typeof DedicatedWorkerGlobalScope !== "undefined",
});
return;
}
if (data.op === "acquire") {
const file = await resolveFile(data.path);
const mode = data.mode ?? "readwrite";
let handle;
try {
handle = await file.createSyncAccessHandle({ mode });
}
catch (error) {
if (mode === "readwrite-unsafe" && error instanceof TypeError) {
reply(requestId, { ok: false, unsupportedMode: true, mode, name: error.name, message: error.message });
return;
}
throw error;
}
handles.set(data.id ?? requestId, handle);
let written = 0;
if (data.write !== undefined) {
written = handle.write(new Uint8Array(data.write), { at: 0 });
handle.flush();
}
reply(requestId, { ok: true, mode, size: handle.getSize(), written });
return;
}
if (data.op === "close") {
const handle = handles.get(data.id ?? requestId);
if (handle) { handle.close(); handles.delete(data.id ?? requestId); }
reply(requestId, { ok: true, closed: Boolean(handle) });
return;
}
if (data.op === "close-all") {
for (const handle of handles.values()) handle.close();
handles.clear();
reply(requestId, { ok: true });
return;
}
reply(requestId, { ok: false, name: "QA_OPERATION_INVALID", message: String(data.op) });
}
catch (error) {
reply(requestId, {
ok: false,
name: error?.name ?? "Error",
message: error?.message ?? String(error),
stack: String(error?.stack ?? "").slice(0, 1200),
});
}
};

File diff suppressed because it is too large Load Diff