Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apiExamples/Embeded_CAD_Integration_Test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand Down Expand Up @@ -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';
Expand Down
13 changes: 12 additions & 1 deletion docs/developer/embedding/cad-embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand All @@ -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.
120 changes: 120 additions & 0 deletions scripts/nonInteractiveSave.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
71 changes: 66 additions & 5 deletions src/UI/cad/CadEmbed.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -434,23 +447,70 @@ export class CadEmbed {
</html>`;
}

async #request(type: any, payload: any): Promise<any> {
#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<any> {
await this.waitUntilReady();
return this.#requestRaw(type, payload);
return this.#requestRaw(type, payload, options);
}

async #requestRaw(type: any, payload: any): Promise<any> {
async #requestRaw(type: any, payload: any, options: any = {}): Promise<any> {
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(
Expand Down Expand Up @@ -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"));
Expand Down
Loading