diff --git a/apiExamples/Embeded_CAD_Integration_Test.ts b/apiExamples/Embeded_CAD_Integration_Test.ts index d89d0f62..2a51f172 100644 --- a/apiExamples/Embeded_CAD_Integration_Test.ts +++ b/apiExamples/Embeded_CAD_Integration_Test.ts @@ -165,6 +165,8 @@ async function runIntegrationSuite() { const counters = { pass: 0, fail: 0, skip: 0 }; const historyEvents = []; + let saveTargetDialogCalls = 0; + let confirmCalls = 0; const runTest = async (name, fn) => { addResultRow(name, 'RUN', 'Running...'); @@ -293,6 +295,121 @@ async function runIntegrationSuite() { return 'history rerun'; }); + const savePath = `cad-embed-tests/non-interactive-${Date.now()}`; + const canceledSavePath = `${savePath}-canceled`; + + await runTest('saveCurrentTo() installs no interactive fallback', async () => { + const frameWindow: any = cad.iframe?.contentWindow; + const fileManager = frameWindow?.viewer?.fileManagerWidget; + assert(fileManager, 'CadEmbed frame FileManagerWidget is unavailable'); + const openSaveTargetDialog = fileManager._openSaveTargetDialog.bind(fileManager); + fileManager._openSaveTargetDialog = (...args) => { + saveTargetDialogCalls += 1; + return openSaveTargetDialog(...args); + }; + frameWindow.confirm = () => { + confirmCalls += 1; + return false; + }; + return 'dialog and confirm calls instrumented'; + }); + + await runTest('saveCurrentTo() requires modelPath without UI', async () => { + await expectReject(() => cad.saveCurrentTo({ source: 'local' }), 'requires a model path'); + assert(saveTargetDialogCalls === 0, 'Missing modelPath opened the Save Target dialog'); + assert(confirmCalls === 0, 'Missing modelPath opened a confirmation dialog'); + assert(cad._pending.size === 0, 'CadEmbed retained a pending missing-path request'); + return 'rejected before frame request'; + }); + + await runTest('saveCurrentTo() creates and verifies an explicit target', async () => { + await cad.removeFile(savePath, { source: 'local' }); + const result = await cad.saveCurrentTo({ + modelPath: savePath, + source: 'local', + overwrite: false, + }); + assert(result?.saved === true, `Expected saved=true, got ${JSON.stringify(result)}`); + assert(result?.modelPath === savePath, `Unexpected saved modelPath: ${result?.modelPath}`); + assert(result?.source === 'local', `Unexpected saved source: ${result?.source}`); + assert(typeof result?.savedAt === 'string' && result.savedAt.length > 0, 'Missing savedAt evidence'); + assert(Number(result?.artifactByteSize) > 0, 'Missing nonzero artifactByteSize evidence'); + const persisted = await cad.readFile(savePath, { source: 'local' }); + assert(persisted?.exists === true, 'Saved target does not exist'); + assert( + typeof persisted?.record?.data3mf === 'string' && persisted.record.data3mf.length > 0, + 'Saved target has no 3MF payload', + ); + assert(saveTargetDialogCalls === 0, 'Explicit save opened the Save Target dialog'); + assert(confirmCalls === 0, 'Explicit save opened a confirmation dialog'); + assert(cad._pending.size === 0, 'CadEmbed retained a pending successful save'); + return `${result.artifactByteSize} bytes`; + }); + + await runTest('saveCurrentTo() reports conflict without confirm', async () => { + const result = await cad.saveCurrentTo({ + modelPath: savePath, + source: 'local', + overwrite: false, + }); + assert(result?.saved === false, 'Conflict unexpectedly reported saved=true'); + assert(result?.reason === 'conflict', `Expected conflict reason, got ${result?.reason}`); + assert(confirmCalls === 0, 'Conflict opened a confirmation dialog'); + assert(saveTargetDialogCalls === 0, 'Conflict opened the Save Target dialog'); + assert(cad._pending.size === 0, 'CadEmbed retained a pending conflict request'); + return 'structured conflict returned'; + }); + + await runTest('saveCurrentTo() overwrites without UI', async () => { + const result = await cad.saveCurrentTo({ + modelPath: savePath, + source: 'local', + overwrite: true, + }); + assert(result?.saved === true, 'Explicit overwrite did not save'); + assert(Number(result?.artifactByteSize) > 0, 'Overwrite returned no byte-size evidence'); + assert(confirmCalls === 0, 'Explicit overwrite opened a confirmation dialog'); + assert(saveTargetDialogCalls === 0, 'Explicit overwrite opened the Save Target dialog'); + assert(cad._pending.size === 0, 'CadEmbed retained a pending overwrite request'); + return 'target replaced'; + }); + + await runTest('saveCurrentTo() timeout cancels without a ghost artifact', async () => { + await cad.removeFile(canceledSavePath, { source: 'local' }); + const previousTimeoutMs = cad._requestTimeoutMs; + cad._requestTimeoutMs = 5; + try { + await expectReject( + () => cad.saveCurrentTo({ + modelPath: canceledSavePath, + source: 'local', + overwrite: false, + }), + 'Request timed out: saveCurrentTo', + ); + } finally { + cad._requestTimeoutMs = previousTimeoutMs; + } + const frameWindow: any = cad.iframe?.contentWindow; + const canceled = await waitFor( + () => Number(frameWindow?.__BREP_CADFrameApp?._requestControllers?.size || 0) === 0, + { timeoutMs: 10_000, intervalMs: 25 }, + ); + assert(canceled, 'Frame-side save did not settle after cancellation'); + const persisted = await cad.readFile(canceledSavePath, { source: 'local' }); + assert(persisted?.exists === false, 'Canceled save left a ghost artifact'); + assert(cad._pending.size === 0, 'CadEmbed retained a timed-out request'); + assert(saveTargetDialogCalls === 0, 'Timed-out save opened the Save Target dialog'); + assert(confirmCalls === 0, 'Timed-out save opened a confirmation dialog'); + return 'frame canceled and target absent'; + }); + + await runTest('saveCurrentTo() cleanup', async () => { + await cad.removeFile(savePath, { source: 'local' }); + await cad.removeFile(canceledSavePath, { source: 'local' }); + return 'test artifacts removed'; + }); + await runTest('loadModel(invalid args rejects)', async () => { await expectReject(() => cad.loadModel(null), 'requires a model path string'); return 'rejection asserted'; diff --git a/docs/developer/embedding/cad-embed.md b/docs/developer/embedding/cad-embed.md index 0483c242..560f5814 100644 --- a/docs/developer/embedding/cad-embed.md +++ b/docs/developer/embedding/cad-embed.md @@ -87,6 +87,15 @@ if (first) { await cad.setCurrentFileName("my/new/model-name"); await cad.saveModel(); // alias of saveCurrent() +const abortController = new AbortController(); +const saved = await cad.saveCurrentTo({ + modelPath: "automation/output", + source: "local", + overwrite: false, + signal: abortController.signal, +}); +console.log(saved.modelPath, saved.savedAt, saved.artifactByteSize); + await cad.runHistory(); await cad.reset(); await cad.destroy(); @@ -143,7 +152,8 @@ await cad.destroy(); - `deleteFile(pathOrRequest, options?)`: alias of `removeFile`. - `setCurrentFile(pathOrRequest, options?)`: sets the active file path/scope used by save operations. - `setCurrentFileName(name, options?)`: alias of `setCurrentFile`. -- `saveCurrent(options?)`: triggers save from the parent page. +- `saveCurrent(options?)`: triggers the interactive save flow from the parent page. It may open the Save Target or overwrite confirmation UI. +- `saveCurrentTo(options)`: saves to a required explicit destination without dialogs. `overwrite` defaults to `false`; GitHub requires `repoFull` and `branch`, mounted storage requires `repoFull`, and an optional `AbortSignal` cancels the frame-side operation. Success includes `modelPath`, storage scope, `savedAt`, and `artifactByteSize`; an existing target returns `{ saved: false, reason: "conflict" }`. - `saveModel(options?)`: alias of `saveCurrent`. - `runHistory()`: reruns current feature history. - `reset()`: clears the model and reruns. @@ -153,3 +163,4 @@ await cad.destroy(); - `mount()` is idempotent for an active instance: if already mounted, it returns the same iframe after readiness. - After `destroy()`, the instance is terminal. Create a new `CadEmbed` instance to mount again. - `viewerOnlyMode` cannot be changed after initialization. +- Request timeout and explicit abort events send a cancellation message to the iframe. Non-interactive saves verify persistence and roll back a started write before the frame operation settles as failed. diff --git a/scripts/nonInteractiveSave.test.ts b/scripts/nonInteractiveSave.test.ts new file mode 100644 index 00000000..101ddf9e --- /dev/null +++ b/scripts/nonInteractiveSave.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + normalizeNonInteractiveSaveRequest, + persistWithRollback, +} from "../src/UI/cad/nonInteractiveSave.js"; + +test("non-interactive save requires an explicit model path", () => { + assert.throws( + () => normalizeNonInteractiveSaveRequest({}), + /requires modelPath/, + ); +}); + +test("non-interactive save validates complete storage scopes", () => { + assert.throws( + () => normalizeNonInteractiveSaveRequest({ + modelPath: "benchmarks/output", + source: "github", + }), + /requires repoFull/, + ); + assert.throws( + () => normalizeNonInteractiveSaveRequest({ + modelPath: "benchmarks/output", + source: "mounted", + }), + /requires repoFull/, + ); + assert.throws( + () => normalizeNonInteractiveSaveRequest({ + modelPath: "benchmarks/output", + source: "github", + repoFull: "owner/repo", + }), + /requires branch/, + ); +}); + +test("non-interactive save plans explicit overwrite behavior", () => { + assert.deepEqual( + normalizeNonInteractiveSaveRequest({ + modelPath: "benchmarks/output.3mf", + source: "github", + repoFull: "owner/repo", + branch: "benchmark", + }), + { + modelPath: "benchmarks/output.3mf", + source: "github", + repoFull: "owner/repo", + branch: "benchmark", + overwrite: false, + }, + ); + assert.equal( + normalizeNonInteractiveSaveRequest({ + modelPath: "benchmarks/output", + overwrite: true, + }).overwrite, + true, + ); +}); + +test("aborted persistence restores the previous target", async () => { + const controller = new AbortController(); + let stored = "previous"; + + await assert.rejects( + persistWithRollback({ + signal: controller.signal, + write: async () => { + stored = "replacement"; + controller.abort(); + }, + verify: async () => stored, + rollback: async () => { + stored = "previous"; + }, + }), + { name: "AbortError" }, + ); + assert.equal(stored, "previous"); +}); + +test("failed persistence verification rolls back a new target", async () => { + let stored: string | null = null; + + await assert.rejects( + persistWithRollback({ + write: async () => { + stored = "replacement"; + }, + verify: async () => { + throw new Error("verification failed"); + }, + rollback: async () => { + stored = null; + }, + }), + /verification failed/, + ); + assert.equal(stored, null); +}); + +test("successful persistence returns verified evidence without rollback", async () => { + let rollbackCount = 0; + + const evidence = await persistWithRollback({ + write: async () => undefined, + verify: async () => ({ savedAt: "2026-07-24T12:00:00.000Z" }), + rollback: async () => { + rollbackCount += 1; + }, + }); + + assert.deepEqual(evidence, { savedAt: "2026-07-24T12:00:00.000Z" }); + assert.equal(rollbackCount, 0); +}); diff --git a/src/UI/cad/CadEmbed.ts b/src/UI/cad/CadEmbed.ts index f61e70c8..c44e5520 100644 --- a/src/UI/cad/CadEmbed.ts +++ b/src/UI/cad/CadEmbed.ts @@ -1,4 +1,5 @@ import { deepClone } from "../../utils/deepClone.js"; +import { createAbortError } from "./nonInteractiveSave.js"; export { bootCadFrame, bootCADFrame } from "./CadFrameApp.js"; const DEFAULT_CHANNEL = "brep:cad"; @@ -335,6 +336,17 @@ export class CadEmbed { return this.#request("saveCurrent", payload); } + async saveCurrentTo(options: any = {}) { + const request = (options && typeof options === "object") ? options : {}; + const modelPath = toFilePath(request.modelPath ?? request.path ?? request.name); + if (!modelPath) throw new Error("saveCurrentTo requires a model path"); + const { signal, ...payload } = request; + return this.#request("saveCurrentTo", deepClone({ + ...payload, + modelPath, + }), { signal }); + } + async saveModel(options: any = {}) { return this.saveCurrent(options); } @@ -369,6 +381,7 @@ export class CadEmbed { for (const [requestId, pending] of this._pending.entries()) { clearTimeout(pending.timer); + try { pending.abortCleanup?.(); } catch { /* ignore abort-listener cleanup failure */ } pending.reject(new Error(`Request aborted: ${requestId}`)); } this._pending.clear(); @@ -434,23 +447,70 @@ export class CadEmbed { `; } - async #request(type: any, payload: any): Promise { + #cancelFrameRequest(requestId, type, message) { + const win = this._iframe?.contentWindow; + if (!win) return; + try { + win.postMessage( + { + channel: this._channel, + instanceId: this._instanceId, + type: "cancel", + requestId, + payload: { + requestType: type, + message, + }, + }, + this._targetOrigin, + ); + } catch { /* ignore best-effort cancellation post failure */ } + } + + async #request(type: any, payload: any, options: any = {}): Promise { await this.waitUntilReady(); - return this.#requestRaw(type, payload); + return this.#requestRaw(type, payload, options); } - async #requestRaw(type: any, payload: any): Promise { + async #requestRaw(type: any, payload: any, options: any = {}): Promise { if (this._destroyed) throw new Error("CadEmbed is destroyed"); const win = this._iframe?.contentWindow; if (!win) throw new Error("CadEmbed iframe is unavailable"); + const signal = options?.signal || null; + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : createAbortError(); + } const requestId = `${this._instanceId}:${++this._requestSeq}`; const request = new Promise((resolve, reject) => { const timer = setTimeout(() => { + const pending = this._pending.get(requestId); + if (!pending) return; this._pending.delete(requestId); - reject(new Error(`Request timed out: ${type}`)); + try { pending.abortCleanup?.(); } catch { /* ignore abort-listener cleanup failure */ } + const message = `Request timed out: ${type}`; + this.#cancelFrameRequest(requestId, type, message); + reject(new Error(message)); }, this._requestTimeoutMs); - this._pending.set(requestId, { resolve, reject, timer }); + + let abortCleanup = null; + if (signal && typeof signal.addEventListener === "function") { + const onAbort = () => { + const pending = this._pending.get(requestId); + if (!pending) return; + this._pending.delete(requestId); + clearTimeout(pending.timer); + try { pending.abortCleanup?.(); } catch { /* ignore abort-listener cleanup failure */ } + const error = signal.reason instanceof Error + ? signal.reason + : createAbortError(); + this.#cancelFrameRequest(requestId, type, error.message); + reject(error); + }; + signal.addEventListener("abort", onAbort, { once: true }); + abortCleanup = () => signal.removeEventListener("abort", onAbort); + } + this._pending.set(requestId, { resolve, reject, timer, abortCleanup }); }); win.postMessage( @@ -511,6 +571,7 @@ export class CadEmbed { const pending = this._pending.get(requestId); this._pending.delete(requestId); clearTimeout(pending.timer); + try { pending.abortCleanup?.(); } catch { /* ignore abort-listener cleanup failure */ } if (msg.ok === false || msg.error) { pending.reject(new Error(msg?.error?.message || "CAD request failed")); diff --git a/src/UI/cad/CadFrameApp.ts b/src/UI/cad/CadFrameApp.ts index ff9fdb38..71ca5b86 100644 --- a/src/UI/cad/CadFrameApp.ts +++ b/src/UI/cad/CadFrameApp.ts @@ -11,6 +11,7 @@ import { MODEL_STORAGE_PREFIX, uint8ArrayToBase64, } from "../../services/componentLibrary.js"; +import { createAbortError } from "./nonInteractiveSave.js"; import "../../styles/cad.css"; declare global { @@ -132,6 +133,7 @@ class CadFrameApp { this._saveHookInstalled = false; this._fileHooksInstalled = false; this._saveInProgress = false; + this._requestControllers = new Map(); this._boundStorageEvent = null; this._boundStorageBackendEvent = null; this._customCssEl = null; @@ -155,6 +157,10 @@ class CadFrameApp { if (this._disposed) return; this._disposed = true; window.removeEventListener("message", this._boundMessage); + for (const controller of this._requestControllers.values()) { + try { controller.abort(createAbortError("CAD frame disposed")); } catch { /* ignore best-effort abort */ } + } + this._requestControllers.clear(); try { this._viewer?.dispose?.(); } catch { /* ignore best-effort CAD frame failure */ } this._viewer = null; this._viewerBootPromise = null; @@ -848,6 +854,30 @@ class CadFrameApp { return this.#collectState(); } + async #saveCurrentTo(input: any = {}, signal: AbortSignal | null = null) { + const fm = this._viewer?.fileManagerWidget; + if (!fm || typeof fm.saveCurrentTo !== "function") { + throw new Error("CAD frame cannot save models non-interactively (FileManagerWidget unavailable)"); + } + + this._saveInProgress = true; + let result; + try { + result = await fm.saveCurrentTo({ + ...((input && typeof input === "object") ? input : {}), + signal, + }); + } finally { + this._saveInProgress = false; + } + + if (result?.saved) { + try { this.#emitSaved("saveCurrentTo", result); } catch { /* save already verified */ } + try { this.#emitFilesChanged("saveCurrentTo", result); } catch { /* save already verified */ } + } + return result; + } + async #handleInit(payload: any = {}) { const requestedViewerOnly = normalizeBoolean(payload?.viewerOnlyMode, this._viewerOnlyMode); if (!this._viewer) { @@ -878,7 +908,7 @@ class CadFrameApp { return this.#collectState(); } - async #handleRequest(type, payload: any) { + async #handleRequest(type, payload: any, signal: AbortSignal | null = null) { if (type === "init") { return this.#handleInit(payload || {}); } @@ -973,6 +1003,10 @@ class CadFrameApp { return this.#saveCurrent(payload || {}); } + if (type === "saveCurrentTo") { + return this.#saveCurrentTo(payload || {}, signal); + } + throw new Error(`Unknown request type: ${type}`); } @@ -995,11 +1029,29 @@ class CadFrameApp { if (!requestId) return; + if (type === "cancel") { + const controller = this._requestControllers.get(requestId); + if (controller && !controller.signal.aborted) { + const message = String(msg?.payload?.message || "CAD request canceled"); + controller.abort(createAbortError(message)); + } + return; + } + + const controller = new AbortController(); + this._requestControllers.set(requestId, controller); try { - const payload = await this.#handleRequest(type, msg.payload || {}); + const payload = await this.#handleRequest(type, msg.payload || {}, controller.signal); + if (controller.signal.aborted) { + throw controller.signal.reason || createAbortError(); + } this.#respond(requestId, true, payload); } catch (error) { this.#respond(requestId, false, null, error); + } finally { + if (this._requestControllers.get(requestId) === controller) { + this._requestControllers.delete(requestId); + } } } } diff --git a/src/UI/cad/nonInteractiveSave.ts b/src/UI/cad/nonInteractiveSave.ts new file mode 100644 index 00000000..1064d0ac --- /dev/null +++ b/src/UI/cad/nonInteractiveSave.ts @@ -0,0 +1,94 @@ +export type NonInteractiveSaveSource = "local" | "github" | "mounted"; + +export type NonInteractiveSaveRequest = { + modelPath: string; + source: NonInteractiveSaveSource; + repoFull: string; + branch: string; + overwrite: boolean; +}; + +type PersistenceTransaction = { + signal?: AbortSignal | null; + write: () => Promise; + verify: () => Promise; + rollback: () => Promise; +}; + +function normalizeSource(value: unknown): NonInteractiveSaveSource { + const source = String(value || "local").trim().toLowerCase(); + if (source === "local" || source === "github" || source === "mounted") { + return source; + } + throw new Error(`saveCurrentTo received unsupported source "${source}"`); +} + +export function createAbortError(message = "Save operation aborted"): Error { + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +export function throwIfSaveAborted(signal?: AbortSignal | null): void { + if (!signal?.aborted) return; + const reason = signal.reason; + if (reason instanceof Error) throw reason; + throw createAbortError(); +} + +export function normalizeNonInteractiveSaveRequest( + input: Record = {}, +): NonInteractiveSaveRequest { + const modelPath = String(input.modelPath ?? input.path ?? input.name ?? "").trim(); + if (!modelPath) throw new Error("saveCurrentTo requires modelPath"); + + const source = normalizeSource(input.source); + const repoFull = source === "local" ? "" : String(input.repoFull || "").trim(); + if (source !== "local" && !repoFull) { + throw new Error(`saveCurrentTo requires repoFull for source "${source}"`); + } + const branch = source === "github" ? String(input.branch || "").trim() : ""; + if (source === "github" && !branch) { + throw new Error('saveCurrentTo requires branch for source "github"'); + } + + return { + modelPath, + source, + repoFull, + branch, + overwrite: input.overwrite === true, + }; +} + +export async function persistWithRollback({ + signal, + write, + verify, + rollback, +}: PersistenceTransaction): Promise { + throwIfSaveAborted(signal); + let writeStarted = false; + try { + writeStarted = true; + await write(); + throwIfSaveAborted(signal); + const evidence = await verify(); + throwIfSaveAborted(signal); + return evidence; + } catch (error) { + if (writeStarted) { + try { + await rollback(); + } catch (rollbackError) { + const message = rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError); + throw new Error(`Save failed and rollback failed: ${message}`, { + cause: error, + }); + } + } + throw error; + } +} diff --git a/src/UI/fileManagerWidget.ts b/src/UI/fileManagerWidget.ts index ee799b31..3e57a2cb 100644 --- a/src/UI/fileManagerWidget.ts +++ b/src/UI/fileManagerWidget.ts @@ -28,9 +28,18 @@ import { readBrowserStorageValue, writeBrowserStorageValue, } from '../utils/browserStorage.js'; +import { + invalidModelRecordError, + requireModelRecord, +} from '../services/modelLoadErrors.js'; import { replaceCurrentCadModelUrl } from '../utils/cadModelUrl.js'; import { CADmaterials } from './CADmaterials.js'; import { FloatingWindow } from './FloatingWindow.js'; +import { + normalizeNonInteractiveSaveRequest, + persistWithRollback, + throwIfSaveAborted, +} from './cad/nonInteractiveSave.js'; import { HISTORY_COLLECTION_REFRESH_EVENT } from './history/HistoryCollectionWidget.js'; import { generateSheetsPdfBytes } from './sheets/Sheet2DEditorWindow.js'; import { WorkspaceFileBrowserWidget } from './WorkspaceFileBrowserWidget.js'; @@ -1164,6 +1173,79 @@ export class FileManagerWidget { } } + return await this._saveCurrentToTarget({ + modelPath, + source: targetSource, + repoFull: targetRepo, + branch: targetBranch, + }, { interactive: true }); + } + + async saveCurrentTo(input: any = {}) { + if (!this.viewer || !this.viewer.partHistory) { + throw new Error('saveCurrentTo is unavailable without an active model'); + } + + const payload = (input && typeof input === 'object') ? input : {}; + const signal = payload.signal || null; + const rawPath = payload.modelPath ?? payload.path ?? payload.name; + const modelPath = stripModelFileExtension(normalizeModelPath(rawPath)); + const request = normalizeNonInteractiveSaveRequest({ + ...payload, + modelPath, + }); + const targetOptions = { + ...this._buildScopeOptions(request.source, request.repoFull, request.branch), + path: request.modelPath, + throwOnError: true, + }; + + throwIfSaveAborted(signal); + const existing = await this._getModel(request.modelPath, targetOptions); + throwIfSaveAborted(signal); + if (existing && !request.overwrite) { + return { + saved: false, + reason: 'conflict', + conflict: true, + modelPath: request.modelPath, + source: request.source, + repoFull: request.repoFull, + branch: request.branch, + }; + } + + const previousRecord = existing + ? { + savedAt: existing.savedAt || null, + data3mf: existing.data3mf || null, + data: existing.data || null, + thumbnail: existing.thumbnail || null, + } + : null; + + return await this._saveCurrentToTarget(request, { + interactive: false, + signal, + previousRecord, + }); + } + + async _saveCurrentToTarget(target: any, options: any = {}) { + const interactive = options?.interactive !== false; + const signal = options?.signal || null; + const previousRecord = options?.previousRecord || null; + const modelPath = String(normalizeModelPath(target?.modelPath || '') || '').trim(); + const targetSource = this._normalizeSource(target?.source || 'local') || 'local'; + const targetRepo = targetSource === 'local' ? '' : String(target?.repoFull || '').trim(); + const targetBranch = targetSource === 'github' ? String(target?.branch || '').trim() : ''; + const targetOptions = { + ...this._buildScopeOptions(targetSource, targetRepo, targetBranch), + path: modelPath, + ...(!interactive ? { throwOnError: true } : {}), + }; + + throwIfSaveAborted(signal); try { console.log('[FileManagerWidget] saveCurrent: begin', { name: modelPath }); } catch { } this._setSaveBusy(true); this._startSaveProgress(targetRepo ? `Saving "${modelPath}" to ${targetRepo}...` : `Saving "${modelPath}"...`); @@ -1171,6 +1253,7 @@ export class FileManagerWidget { this._logSaveProgress('Preparing feature history...'); // Get feature history JSON (now includes PMI views) and embed into a 3MF archive as Metadata/featureHistory.json let jsonString = await this.viewer.partHistory.toJSON(); + throwIfSaveAborted(signal); try { console.log('[FileManagerWidget] saveCurrent: feature history', { bytes: jsonString ? jsonString.length : 0 }); } catch { } let additionalFiles = {}; let modelMetadata = undefined; @@ -1182,15 +1265,18 @@ export class FileManagerWidget { try { this._logSaveProgress('Capturing PMI view images...'); const viewFiles = await this.viewer?.pmiViewsWidget?.captureViewImagesForPackage?.(); + throwIfSaveAborted(signal); if (viewFiles && typeof viewFiles === 'object') { additionalFiles = { ...(additionalFiles || {}), ...viewFiles }; } } catch (err) { + throwIfSaveAborted(signal); console.error('Failed to embed PMI view images:', err); } try { this._logSaveProgress('Generating 2D sheets PDF...'); const sheetsPdf = await generateSheetsPdfBytes(this.viewer); + throwIfSaveAborted(signal); if (sheetsPdf instanceof Uint8Array && sheetsPdf.length) { additionalFiles = { ...(additionalFiles || {}), 'sheets.pdf': sheetsPdf }; modelMetadata = { ...(modelMetadata || {}), sheetsPdfPath: '/sheets.pdf' }; @@ -1220,18 +1306,26 @@ export class FileManagerWidget { } try { const finalJsonString = await this.viewer.partHistory.toJSON(); + throwIfSaveAborted(signal); if (finalJsonString) { jsonString = finalJsonString; additionalFiles['Metadata/featureHistory.json'] = finalJsonString; modelMetadata = { ...(modelMetadata || {}), featureHistoryPath: '/Metadata/featureHistory.json' }; } - } catch { /* keep the initial history snapshot */ } + } catch { + throwIfSaveAborted(signal); + // Keep the initial history snapshot. + } // Capture a higher-resolution thumbnail of the current view let thumbnail = null; try { this._logSaveProgress('Capturing thumbnail...'); thumbnail = await this._captureThumbnail(THUMBNAIL_CAPTURE_SIZE); - } catch { /* ignore thumbnail failures */ } + throwIfSaveAborted(signal); + } catch { + throwIfSaveAborted(signal); + // Ignore thumbnail failures. + } // Collect solids for full 3MF export (so slicers can open it). this._logSaveProgress('Collecting solids...'); @@ -1291,8 +1385,10 @@ export class FileManagerWidget { defaultFaceColor, includeFaceTags: false, }); + throwIfSaveAborted(signal); try { console.log('[FileManagerWidget] saveCurrent: 3MF exported', { bytes: threeMfBytes?.length || 0 }); } catch { } } catch (e) { + throwIfSaveAborted(signal); // Fallback: history only 3MF const metadataManager = this.viewer?.partHistory?.metadataManager || null; const defaultFaceColor = (() => { @@ -1316,6 +1412,7 @@ export class FileManagerWidget { defaultFaceColor, includeFaceTags: false, }); + throwIfSaveAborted(signal); console.warn('[FileManagerWidget] 3MF export failed for solids, saved history-only 3MF.', e); try { console.log('[FileManagerWidget] saveCurrent: 3MF exported (history only)', { bytes: threeMfBytes?.length || 0 }); } catch { } } @@ -1325,7 +1422,55 @@ export class FileManagerWidget { // Persist the model plus optional captured thumbnail sidecar metadata. const record: any = { savedAt: now, data3mf: threeMfB64 }; if (thumbnail) record.thumbnail = thumbnail; - if (targetSource === 'github') { + let persistedRecord = null; + if (!interactive) { + if (targetSource === 'github') { + this._logSaveProgress(`Saving to GitHub${targetRepo ? ` (${targetRepo})` : ''}...`); + try { console.log('[FileManagerWidget] saveCurrentTo: saving to GitHub', { name: modelPath, repo: targetRepo }); } catch { } + } else if (targetSource === 'mounted') { + this._logSaveProgress(`Saving to mounted folder${targetRepo ? ` (${targetRepo})` : ''}...`); + try { console.log('[FileManagerWidget] saveCurrentTo: saving to mounted folder', { name: modelPath, mountId: targetRepo }); } catch { } + } else { + this._logSaveProgress('Saving to local storage...'); + try { console.log('[FileManagerWidget] saveCurrentTo: saving locally', { name: modelPath }); } catch { } + } + + persistedRecord = await persistWithRollback({ + signal, + write: async () => { + throwIfSaveAborted(signal); + await this._setModel(modelPath, record, targetOptions); + }, + verify: async () => { + const persisted = await this._getModel(modelPath, targetOptions); + if (!persisted) { + throw new Error(`saveCurrentTo failed to persist "${modelPath}"`); + } + if (persisted.data3mf !== threeMfB64) { + throw new Error(`saveCurrentTo persistence verification failed for "${modelPath}"`); + } + if (String(persisted.savedAt || '') !== now) { + throw new Error(`saveCurrentTo timestamp verification failed for "${modelPath}"`); + } + return persisted; + }, + rollback: async () => { + if (previousRecord) { + await this._setModel(modelPath, previousRecord, targetOptions); + } else { + await this._removeModel(modelPath, targetOptions); + } + const restored = await this._getModel(modelPath, targetOptions); + if (previousRecord) { + if (!restored || restored.data3mf !== previousRecord.data3mf) { + throw new Error(`saveCurrentTo could not restore "${modelPath}"`); + } + } else if (restored) { + throw new Error(`saveCurrentTo could not remove aborted target "${modelPath}"`); + } + }, + }); + } else if (targetSource === 'github') { this._logSaveProgress(`Saving to GitHub${targetRepo ? ` (${targetRepo})` : ''}...`); try { console.log('[FileManagerWidget] saveCurrent: saving to GitHub', { name: modelPath, repo: targetRepo }); } catch { } const res = await this._retryGithubOperation( @@ -1358,17 +1503,35 @@ export class FileManagerWidget { try { if (thumbnail) this._thumbCache.set(this._recordScopeKey(modelPath, targetSource, targetRepo), thumbnail); } catch { } - this.currentName = modelPath; - this.currentRepoFull = targetRepo; - this.currentSource = targetSource; - this.currentBranch = targetBranch; - this._forceSaveTargetDialog = false; - this.nameInput.value = modelPath; - this._syncSavedModelUrl(modelPath, targetSource, targetRepo, targetBranch); - const savedSnapshot = await this._refreshSavedHistorySnapshot(); - if (savedSnapshot === null) this._markSavedHistorySnapshot(jsonString || null); - this._logSaveProgress('Refreshing list...'); - await this.refreshList(); + if (interactive) { + this.currentName = modelPath; + this.currentRepoFull = targetRepo; + this.currentSource = targetSource; + this.currentBranch = targetBranch; + this._forceSaveTargetDialog = false; + this.nameInput.value = modelPath; + this._syncSavedModelUrl(modelPath, targetSource, targetRepo, targetBranch); + const savedSnapshot = await this._refreshSavedHistorySnapshot(); + if (savedSnapshot === null) this._markSavedHistorySnapshot(jsonString || null); + this._logSaveProgress('Refreshing list...'); + await this.refreshList(); + } else { + try { + this.currentName = modelPath; + this.currentRepoFull = targetRepo; + this.currentSource = targetSource; + this.currentBranch = targetBranch; + this._forceSaveTargetDialog = false; + if (this.nameInput) this.nameInput.value = modelPath; + this._syncSavedModelUrl(modelPath, targetSource, targetRepo, targetBranch); + this._markSavedHistorySnapshot(jsonString || null); + } catch (error) { + try { console.warn('[FileManagerWidget] saveCurrentTo: post-save UI update failed', error); } catch { } + } + void Promise.resolve().then(() => this.refreshList()).catch((error) => { + try { console.warn('[FileManagerWidget] saveCurrentTo: list refresh failed', error); } catch { } + }); + } this._logSaveProgress('Save complete.'); try { console.log('[FileManagerWidget] saveCurrent: complete', { name: modelPath }); } catch { } if (skipped.length) { @@ -1380,6 +1543,8 @@ export class FileManagerWidget { source: targetSource, repoFull: targetRepo, branch: targetBranch, + savedAt: String(persistedRecord?.savedAt || now), + artifactByteSize: Number(threeMfBytes?.byteLength || threeMfBytes?.length || 0), }; } catch (err) { const msg = (err && err.message) ? err.message : String(err || 'Unknown error'); @@ -1612,7 +1777,7 @@ export class FileManagerWidget { } async _loadModelRecord(name, rec, options: any = {}, source = 'local', seq = this._loadSeq, refreshReason = 'load-model') { - if (!rec) return alert('Model not found.'); + rec = requireModelRecord(name, rec); await this.viewer.partHistory.reset(); // Prefer new 3MF-based storage if (rec.data3mf && typeof rec.data3mf === 'string') { @@ -1686,9 +1851,8 @@ export class FileManagerWidget { // Sync Expressions UI with imported code try { this.viewer?.expressionsManager?.refreshFromPartHistory?.(); } catch { } } catch (e) { - alert('Failed to load model (invalid data).'); console.error(e); - return; + throw invalidModelRecordError(name); } if (seq !== this._loadSeq) return; this._applyLoadedModelState(name, options, rec, source); diff --git a/src/services/modelLoadErrors.ts b/src/services/modelLoadErrors.ts new file mode 100644 index 00000000..7f840412 --- /dev/null +++ b/src/services/modelLoadErrors.ts @@ -0,0 +1,23 @@ +function normalizeModelPath(input) { + const raw = String(input || '').replace(/\\/g, '/'); + const out = []; + for (const part of raw.split('/')) { + const token = String(part || '').trim(); + if (!token || token === '.' || token === '..') continue; + out.push(token); + } + return out.join('/'); +} + +export function requireModelRecord(name, record) { + if (record) return record; + const modelPath = normalizeModelPath(name); + throw new Error(modelPath ? `Model not found: "${modelPath}"` : 'Model not found.'); +} + +export function invalidModelRecordError(name) { + const modelPath = normalizeModelPath(name); + return new Error(modelPath + ? `Failed to load model "${modelPath}" (invalid data).` + : 'Failed to load model (invalid data).'); +} diff --git a/src/tests/test_file_manager_programmatic_load_errors.ts b/src/tests/test_file_manager_programmatic_load_errors.ts new file mode 100644 index 00000000..c2a12c82 --- /dev/null +++ b/src/tests/test_file_manager_programmatic_load_errors.ts @@ -0,0 +1,34 @@ +import { requireModelRecord } from '../services/modelLoadErrors.js'; + +export async function test_file_manager_missing_model_rejects_without_alert() { + const previousAlert = globalThis.alert; + let alertCalls = 0; + globalThis.alert = () => { + alertCalls += 1; + }; + + try { + let error = null; + try { + requireModelRecord('examples/missing-model.3mf', null); + } catch (caught) { + error = caught; + } + + if (!(error instanceof Error)) { + throw new Error('Expected a missing model to reject the programmatic load request'); + } + if (error.message !== 'Model not found: "examples/missing-model.3mf"') { + throw new Error(`Unexpected missing-model error: ${error.message}`); + } + if (alertCalls !== 0) { + throw new Error(`Expected no blocking alert, received ${alertCalls}`); + } + } finally { + if (previousAlert === undefined) { + delete globalThis.alert; + } else { + globalThis.alert = previousAlert; + } + } +} diff --git a/src/tests/tests.ts b/src/tests/tests.ts index 3e9b17a3..6af00247 100644 --- a/src/tests/tests.ts +++ b/src/tests/tests.ts @@ -111,6 +111,7 @@ import { test_extrude_intersect_coplanar_face_merge, } from './test_extrude_intersect_coplanar_face_merge.js'; import { test_face_source_feature_seed } from './test_face_source_feature_seed.js'; +import { test_file_manager_missing_model_rejects_without_alert } from './test_file_manager_programmatic_load_errors.js'; import { afterRun_extrude_solid_face_uses_boundary_edge_sidewalls, test_ExtrudeFace, @@ -925,6 +926,7 @@ function getRequestedTestFunctions(testFunctionsToSearch, requestedTestName) { export const testFunctions: any[] = [ + { test: test_file_manager_missing_model_rejects_without_alert, printArtifacts: false, exportFaces: false, exportSolids: false, resetHistory: true }, { test: test_browser_skip_metadata_for_local_file_tests, printArtifacts: false, exportFaces: false, exportSolids: false, resetHistory: true }, { test: test_cppNative_prepareManifoldMesh_matches_legacy_js_reference, printArtifacts: false, exportFaces: false, exportSolids: false, resetHistory: true }, { test: test_cppSolidCore_preserves_face_ids_and_metadata, printArtifacts: false, exportFaces: false, exportSolids: false, resetHistory: true },