test(web): add one-pass feature smoke plan and record blockers
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 06:48:03 +08:00
parent 91cb389a82
commit 0bc56836c5
15 changed files with 1702 additions and 22 deletions

View File

@@ -27,7 +27,7 @@ import { coreSceneCommands } from "../../../protocol/core-scene-commands";
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
import { animationRenderCommands } from "../../../protocol/animation-render-commands";
import { ioMediaCommands } from "../../../protocol/io-media-commands";
import { ioMediaCommands, type IOSceneFormat } from "../../../protocol/io-media-commands";
import { isWorkbenchCommandAvailable } from "../../../protocol/workbench-command-groups";
import { shaderGraphCommands } from "../../../protocol/shader-graph-commands";
import { renderCompositorCommands } from "../../../protocol/render-compositor-commands";
@@ -122,6 +122,14 @@ function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
return "项目元数据不一致";
}
function encodeBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)));
return btoa(binary);
}
function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult | null; onCleanup: () => void }) {
const values = budget ? [
["项目", budget.projectBytes],
@@ -2715,7 +2723,8 @@ export function App() {
return;
}
if (event.key === "F3") {
if (interactiveTarget && target?.matches("input, textarea, select, [contenteditable='true']")) return;
const textEntryTarget = target?.matches("textarea, select, [contenteditable='true'], input:not([type='file'])");
if (interactiveTarget && textEntryTarget) return;
event.preventDefault();
dispatchUI({ type: "toggleOperatorSearch", open: true });
return;
@@ -2969,6 +2978,56 @@ export function App() {
return gpuAssets;
};
const importSceneFile = async (file: File, format: IOSceneFormat): Promise<void> => {
const identity = nextUserActionIdentity("OPEN");
if (!acquireProjectActionLock(identity)) return;
startUserAction(identity);
const controller = new AbortController();
openAbortControllerRef.current = controller;
const client = webClientRef.current;
try {
if (!client) {
failUserAction(identity, "OPEN_ENGINE_UNAVAILABLE");
return;
}
const input = await readFileBytes(file, { signal: controller.signal });
const inputSha256 = await sha256Hex(input);
const result = await client.importScene(format, file.name, file.type || "application/octet-stream", encodeBase64(input));
const projectId = file.name.replace(/\.[^.]+$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
projectIdRef.current = projectId;
projectDisplayNameRef.current = file.name;
rememberLastProjectId(projectId);
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([]);
setSnapshot(result.snapshot);
setSelectedObjectIds(new Set(result.snapshot.activeObjectId ? [result.snapshot.activeObjectId] : []));
setGeometryBuffers(result.geometryBuffers);
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
setFrame(result.snapshot.frame.current);
setEngineStatus(`Engine: ${format} imported 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) {
failUserAction(identity, error instanceof FileByteReadError ? error.code : "IO_IMPORT_FAILED");
setEngineStatus(recordDiagnostic("ENGINE", "BLEND_OPEN_FAILED", error, { fileName: file.name, format }));
}
finally {
if (openAbortControllerRef.current === controller) openAbortControllerRef.current = null;
releaseProjectActionLock(identity);
}
};
const openBlendFile = async (file: File): Promise<void> => {
const identity = nextUserActionIdentity("OPEN");
if (!acquireProjectActionLock(identity)) return;
@@ -3497,7 +3556,7 @@ export function App() {
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
</nav>
<div className="topbar-actions"><select aria-label="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}><option value="">最近项目 ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}>打开</button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}>关闭</button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}>恢复</button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}>保存</button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>诊断</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else void openBlendFile(file); } event.target.value = ""; }} />
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) void importSceneFile(file, route.format); else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); } } event.target.value = ""; }} />
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}

View File

@@ -245,6 +245,21 @@ export class WebEngineClient {
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
}
async importScene(format: string, fileName: string, mimeType: string, base64: string): Promise<BlendOpenResult & { delta: SceneDelta; importResult?: unknown }> {
const result = await this.request({ type: "jsonCommand", cmd: "importScene", params: { format, fileName, mimeType, base64 } });
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "IO 导入未返回场景结果", true);
this.geometryBuffers = result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return {
status: result.status,
snapshot: result.snapshot,
geometryBuffers: [...this.geometryBuffers],
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
importResult: result.json,
};
}
async snapshot(): Promise<BlendOpenResult> {
const result = await this.request({ type: "snapshot" });
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneIR", true);