flash555588_ai-model-workbench/scripts/verify-obsidian.mjs
2026-06-22 20:54:22 +08:00

887 lines
38 KiB
JavaScript

import { spawn, spawnSync } from "node:child_process";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { basename, dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { chromium } from "playwright-core";
import process from "node:process";
import { fileURLToPath } from "node:url";
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
const vaultDir = parseArg("--vault") ? resolve(parseArg("--vault")) : join(tmpdir(), "ai-model-workbench-verify-vault");
const noteName = "AI Model Workbench Obsidian Verification.md";
const notePath = join(vaultDir, noteName);
const modelDir = join(vaultDir, "models");
const workbenchModelVaultPath = "models/rubiks-cube-3x3.glb";
const conversionFailureVaultPath = "models/needs-converter.fbx";
const obsidianApp = parseArg("--obsidian") ?? process.env.OBSIDIAN_APP ?? defaultObsidianApp();
const debugPort = Number(parseArg("--debug-port") ?? process.env.OBSIDIAN_DEBUG_PORT ?? 9222);
const pluginId = JSON.parse(await readFile(join(rootDir, "manifest.json"), "utf8")).id;
const pluginFiles = ["main.js", "manifest.json", "styles.css"];
const releaseTag = parseArg("--release-tag") ?? process.env.AI3D_RELEASE_TAG ?? null;
const releaseDir = parseArg("--release-dir") ? resolve(parseArg("--release-dir")) : null;
const shouldClean = process.argv.includes("--clean");
const noteContent = [
"# AI Model Workbench Obsidian Verification",
"",
"```3d",
"models/rubiks-cube-3x3.glb",
"```",
"",
"```3d",
"models/test.stl",
"```",
"",
"```3d",
conversionFailureVaultPath,
"```",
"",
].join("\n");
function parseArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
function defaultObsidianApp() {
if (process.platform === "darwin") {
return "/Applications/Obsidian.app";
}
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? "";
return join(localAppData, "Programs", "Obsidian", "Obsidian.exe");
}
return "obsidian";
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
encoding: "utf8",
...options,
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed\n${result.stdout}${result.stderr}`);
}
return typeof result.stdout === "string" ? result.stdout.trim() : "";
}
async function copyPluginAsset(file, targetDir) {
if (releaseDir) {
await copyFile(join(releaseDir, file), join(targetDir, file));
return;
}
if (releaseTag) {
const url = `https://github.com/flash555588/ai-model-workbench/releases/download/${releaseTag}/${file}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download release asset ${file} from ${releaseTag}: HTTP ${response.status}`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
await writeFile(join(targetDir, file), bytes);
return;
}
await copyFile(join(rootDir, file), join(targetDir, file));
}
function obsidianDirFor(vault) {
return basename(vault) === ".obsidian" ? vault : join(vault, ".obsidian");
}
function obsidianConfigPath() {
if (process.platform === "darwin") {
return process.env.HOME ? join(process.env.HOME, "Library", "Application Support", "obsidian", "obsidian.json") : null;
}
if (process.platform === "win32") {
const appData = process.env.APPDATA ?? (process.env.HOME ? join(process.env.HOME, "AppData", "Roaming") : null);
return appData ? join(appData, "obsidian", "obsidian.json") : null;
}
const configHome = process.env.XDG_CONFIG_HOME ?? (process.env.HOME ? join(process.env.HOME, ".config") : null);
return configHome ? join(configHome, "obsidian", "obsidian.json") : null;
}
function vaultConfigId(vault) {
return createHash("sha1").update(vault).digest("hex").slice(0, 16);
}
async function prepareVault() {
await mkdir(join(vaultDir, ".obsidian"), { recursive: true });
await mkdir(modelDir, { recursive: true });
await copyFile(join(rootDir, "models", "rubiks-cube-3x3.glb"), join(modelDir, "rubiks-cube-3x3.glb"));
await copyFile(join(rootDir, "models", "test.stl"), join(modelDir, "test.stl"));
await writeFile(join(vaultDir, conversionFailureVaultPath), "FBX-DUMMY\n", "utf8");
await writeFile(notePath, noteContent, "utf8");
const targetDir = join(obsidianDirFor(vaultDir), "plugins", pluginId);
await mkdir(targetDir, { recursive: true });
for (const file of pluginFiles) {
await copyPluginAsset(file, targetDir);
}
await writeFile(join(targetDir, "data.json"), `${JSON.stringify({
settings: {
experimentalThreeWorkbench: true,
previewRendererRollout: "three-direct-glb",
useThreeRenderer: true,
},
}, null, 2)}\n`, "utf8");
await enablePlugin(obsidianDirFor(vaultDir));
}
async function enablePlugin(obsidianDir) {
const enabledPath = join(obsidianDir, "community-plugins.json");
let enabled = [];
if (existsSync(enabledPath)) {
const parsed = JSON.parse(await readFile(enabledPath, "utf8"));
if (Array.isArray(parsed)) {
enabled = parsed;
}
}
if (!enabled.includes(pluginId)) {
enabled.push(pluginId);
}
await writeFile(enabledPath, `${JSON.stringify(enabled, null, 2)}\n`, "utf8");
}
async function registerVault() {
if (process.argv.includes("--skip-register")) {
return;
}
const configPath = obsidianConfigPath();
if (!configPath) {
return;
}
await mkdir(dirname(configPath), { recursive: true });
let config = { vaults: {} };
if (existsSync(configPath)) {
config = JSON.parse(await readFile(configPath, "utf8"));
if (!config || typeof config !== "object") {
config = { vaults: {} };
}
}
if (!config.vaults || typeof config.vaults !== "object") {
config.vaults = {};
}
const vaultId = vaultConfigId(vaultDir);
config.vaults[vaultId] = {
path: vaultDir,
ts: Date.now(),
open: true,
};
await writeFile(configPath, `${JSON.stringify(config)}\n`, "utf8");
}
async function unregisterVault() {
if (process.argv.includes("--skip-register")) {
return;
}
const configPath = obsidianConfigPath();
if (!configPath || !existsSync(configPath)) {
return;
}
const config = JSON.parse(await readFile(configPath, "utf8"));
if (!config?.vaults || typeof config.vaults !== "object") {
return;
}
delete config.vaults[vaultConfigId(vaultDir)];
for (const [id, entry] of Object.entries(config.vaults)) {
if (entry && typeof entry === "object" && entry.path === vaultDir) {
delete config.vaults[id];
}
}
await writeFile(configPath, `${JSON.stringify(config)}\n`, "utf8");
}
async function cleanupVault() {
await closeObsidian();
await unregisterVault();
await rm(vaultDir, { recursive: true, force: true });
}
async function closeObsidian() {
if (process.platform === "darwin") {
spawnSync("osascript", ["-e", "tell application \"Obsidian\" to quit"], { stdio: "ignore" });
} else if (process.platform === "win32") {
spawnSync("taskkill", ["/IM", "Obsidian.exe", "/F"], { stdio: "ignore", windowsHide: true });
} else {
spawnSync("pkill", ["-f", "Obsidian"], { stdio: "ignore" });
}
await sleep(1000);
}
async function waitForDebugEndpoint() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
if (response.ok) {
return;
}
} catch {
// Retry until Obsidian opens the debugging endpoint.
}
await sleep(500);
}
throw new Error(`Obsidian debugging endpoint did not open on port ${debugPort}`);
}
async function openObsidian() {
assert(existsSync(obsidianApp), `Obsidian app not found at ${obsidianApp}`);
await closeObsidian();
if (process.platform === "darwin") {
spawn("open", [
"-na",
obsidianApp,
"--args",
`--remote-debugging-port=${debugPort}`,
], {
cwd: rootDir,
detached: true,
stdio: "ignore",
}).unref();
} else {
spawn(obsidianApp, [
`--remote-debugging-port=${debugPort}`,
], {
cwd: rootDir,
detached: true,
stdio: "ignore",
windowsHide: true,
}).unref();
}
await waitForDebugEndpoint();
openObsidianUri(`obsidian://open?path=${encodeURIComponent(notePath)}`);
}
function openObsidianUri(uri) {
if (process.platform === "darwin") {
run("open", ["-a", "Obsidian", uri]);
return;
}
if (process.platform === "win32") {
spawnSync("cmd", ["/c", "start", "", uri], { stdio: "ignore", windowsHide: true });
return;
}
spawnSync("xdg-open", [uri], { stdio: "ignore" });
}
async function trustVaultIfPrompted(page) {
const robustClicked = await page.evaluate(() => {
const trustPattern = /(trust author|trust vault|enable plugins|\u4fe1\u4efb|\u542f\u7528\u63d2\u4ef6)/i;
const trustButton = Array.from(document.querySelectorAll("button"))
.find((button) => trustPattern.test((button.textContent ?? "").trim()));
if (!trustButton) {
return false;
}
trustButton.click();
return true;
});
if (robustClicked) {
await page.waitForFunction(() => {
const trustPattern = /(Trust this vault|trust author|enable plugins|\u4fe1\u4efb|\u542f\u7528\u63d2\u4ef6)/i;
return !trustPattern.test(document.body.innerText ?? "");
}, null, { timeout: 15_000 }).catch(() => {});
await sleep(1000);
return;
}
const clicked = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll("button"));
const trustButton = buttons.find((button) => {
const text = (button.textContent ?? "").trim().toLowerCase();
return text.includes("trust author")
|| text.includes("trust vault")
|| text.includes("enable plugins")
|| text.includes("信任仓库作者")
|| text.includes("启用插件");
});
if (!trustButton) {
return false;
}
trustButton.click();
return true;
});
if (clicked) {
await page.waitForFunction(() => {
const text = document.body.innerText;
return !text.includes("Trust this vault")
&& !text.includes("信任仓库作者")
&& !text.includes("启用插件");
}, null, { timeout: 15_000 }).catch(() => {});
await sleep(1000);
}
}
async function verifyPage() {
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${debugPort}`);
try {
const deadline = Date.now() + 30_000;
let page = null;
while (Date.now() < deadline) {
const pages = browser.contexts().flatMap((context) => context.pages());
for (const candidate of pages) {
if (!candidate.url().startsWith("app://obsidian.md")) {
continue;
}
const matchesVault = await candidate.evaluate((expectedVault) => {
return window.app?.vault?.adapter?.basePath === expectedVault;
}, vaultDir).catch(() => false);
if (matchesVault) {
page = candidate;
break;
}
}
if (page) {
break;
}
await sleep(500);
}
assert(page, "Obsidian page not found through remote debugging");
await trustVaultIfPrompted(page);
await page.waitForFunction(() => {
return window.app?.workspace?.layoutReady === true
&& !!document.querySelector(".workspace-leaf");
}, null, { timeout: 20_000 });
await page.evaluate(() => {
window.__ai3dVerifyOpenFile = async (file) => {
const workspace = window.app?.workspace;
if (!workspace) {
throw new Error("Obsidian workspace unavailable");
}
if (workspace.layoutReady === false && workspace.onLayoutReady) {
await new Promise((resolve) => workspace.onLayoutReady(resolve));
}
const leafGetters = [
() => workspace.getMostRecentLeaf?.(),
() => workspace.activeLeaf,
() => workspace.getLeaf?.(false),
() => workspace.getLeaf?.(true),
() => workspace.getLeaf?.("tab"),
];
for (const getLeaf of leafGetters) {
try {
const leaf = getLeaf();
if (leaf?.openFile) {
await leaf.openFile(file, { active: true });
return;
}
} catch {
// Try the next workspace leaf strategy.
}
}
throw new Error("No Obsidian leaf was available for opening files");
};
});
await page.waitForFunction((targetNote) => {
return !!window.app?.vault?.getAbstractFileByPath?.(targetNote);
}, noteName, { timeout: 20_000 });
await page.evaluate(async ({ targetNote, content }) => {
let file = window.app?.vault?.getAbstractFileByPath?.(targetNote);
if (file) {
await window.app.vault.modify(file, content);
} else {
file = await window.app.vault.create(targetNote, content);
}
await window.__ai3dVerifyOpenFile(file);
}, { targetNote: noteName, content: noteContent });
await page.evaluate(async (targetPluginId) => {
const plugins = window.app?.plugins;
const appId = window.app?.appId;
if (appId) {
localStorage.setItem(`enable-plugin-${appId}`, "true");
}
await plugins?.setEnable?.(true);
if (!plugins?.plugins?.[targetPluginId]) {
await plugins?.enablePluginAndSave?.(targetPluginId);
}
}, pluginId);
await page.waitForFunction(() => {
return !!window.app?.plugins?.enabledPlugins?.has?.("ai-model-workbench")
&& !!window.app?.plugins?.plugins?.["ai-model-workbench"];
}, null, { timeout: 20_000 });
await page.waitForFunction((targetNote) => {
return window.app?.workspace?.getActiveFile?.()?.path === targetNote
&& document.querySelectorAll(".ai3d-preview-host canvas").length >= 2;
}, noteName, { timeout: 20_000 });
await page.evaluate(() => {
const hosts = Array.from(document.querySelectorAll(".ai3d-preview-host"));
hosts.at(-1)?.scrollIntoView({ block: "center" });
});
await page.waitForFunction(() => {
return document.querySelectorAll(".ai3d-load-feedback").length >= 1;
}, null, { timeout: 15_000 });
await page.waitForTimeout(1500);
const result = await page.evaluate(() => {
function canvasStats(canvas) {
const sample = document.createElement("canvas");
sample.width = 64;
sample.height = 64;
const context = sample.getContext("2d");
context.drawImage(canvas, 0, 0, 64, 64);
const data = context.getImageData(0, 0, 64, 64).data;
let nonEmpty = 0;
let min = 255;
let max = 0;
for (let index = 0; index < data.length; index += 4) {
const value = Math.max(data[index], data[index + 1], data[index + 2]);
if (data[index + 3] > 0 && value > 8) {
nonEmpty++;
}
min = Math.min(min, data[index], data[index + 1], data[index + 2]);
max = Math.max(max, data[index], data[index + 1], data[index + 2]);
}
return {
width: canvas.width,
height: canvas.height,
nonEmptyRatio: nonEmpty / 4096,
contrast: max - min,
};
}
return {
title: document.title,
activeFile: window.app?.workspace?.getActiveFile?.()?.path ?? null,
pluginEnabled: !!window.app?.plugins?.enabledPlugins?.has?.("ai-model-workbench"),
pluginLoaded: !!window.app?.plugins?.plugins?.["ai-model-workbench"],
previewHostCount: document.querySelectorAll(".ai3d-preview-host").length,
helperToolbarCount: document.querySelectorAll(".ai3d-helper-toolbar").length,
loadFeedback: Array.from(document.querySelectorAll(".ai3d-load-feedback")).map((el) => (el.textContent ?? "").trim()),
ai3dErrors: Array.from(document.querySelectorAll(".ai3d-error")).map((el) => (el.textContent ?? "").trim()),
canvases: Array.from(document.querySelectorAll(".ai3d-preview-host canvas")).map(canvasStats),
};
});
assert(result.pluginEnabled, "Plugin is not enabled in Obsidian");
assert(result.pluginLoaded, "Plugin is not loaded in Obsidian");
assert(result.activeFile === noteName, `Unexpected active file: ${result.activeFile}`);
assert(result.previewHostCount >= 2, `Expected 2 preview hosts, got ${result.previewHostCount}`);
assert(result.helperToolbarCount >= 2, `Expected 2 helper toolbars, got ${result.helperToolbarCount}`);
assert(result.loadFeedback.length >= 1, "Expected conversion-chain load feedback for FBX without converter");
assert(result.loadFeedback.some((text) => text.includes("FBX2glTF")), "Conversion-chain feedback does not mention FBX2glTF");
assert(result.ai3dErrors.length === 0, `Plugin rendered errors: ${result.ai3dErrors.join("; ")}`);
assert(result.canvases.length >= 2, `Expected at least 2 preview canvases, got ${result.canvases.length}`);
const renderedCanvases = result.canvases.filter((canvas) => canvas.nonEmptyRatio > 0.05 && canvas.contrast > 20);
assert(renderedCanvases.length >= 2, `Expected at least 2 rendered canvases, got ${renderedCanvases.length}`);
for (const [index, canvas] of result.canvases.entries()) {
assert(canvas.width > 0 && canvas.height > 0, `Canvas ${index} has invalid size`);
}
console.log(JSON.stringify(result, null, 2));
const directView = await verifyDirectWorkbench(page);
console.log(JSON.stringify({ directView }, null, 2));
} finally {
await browser.close();
}
}
async function verifyDirectWorkbench(page) {
await page.evaluate(async (modelPath) => {
const file = window.app?.vault?.getAbstractFileByPath?.(modelPath);
if (!file) {
throw new Error(`Missing workbench model: ${modelPath}`);
}
await window.__ai3dVerifyOpenFile(file);
}, workbenchModelVaultPath);
await page.waitForFunction(() => {
return document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas")
&& document.querySelector(".ai3d-direct-view .ai3d-helper-toolbar");
}, null, { timeout: 20_000 });
await page.waitForFunction(() => {
const host = document.querySelector(".ai3d-direct-view .ai3d-preview-host");
const panel = document.querySelector(".ai3d-direct-workbench-panel");
const view = document.querySelector(".ai3d-direct-view");
return host?.getAttribute("data-ai3d-backend") === "three"
&& panel?.getAttribute("data-ai3d-backend") === "three"
&& view?.querySelector('[data-ai3d-action="generate-note"]')
&& view?.querySelector('[data-ai3d-action="open-index"]')
&& !view?.querySelector('[data-ai3d-action="set-explode"]');
}, null, { timeout: 20_000 });
await page.waitForTimeout(1200);
const before = await page.evaluate(() => {
const canvas = document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas");
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error("Direct workbench canvas missing");
}
return canvas.toDataURL("image/png");
});
await page.locator('.ai3d-direct-view [data-ai3d-action="toggle-focus"]').click();
await page.waitForFunction(() => {
const button = document.querySelector('.ai3d-direct-view [data-ai3d-action="toggle-focus"]');
return button?.getAttribute("aria-pressed") === "true";
}, null, { timeout: 5_000 });
await page.locator('.ai3d-direct-view [data-ai3d-action="toggle-disassembly"]').click();
await page.waitForFunction(() => {
const button = document.querySelector('.ai3d-direct-view [data-ai3d-action="toggle-disassembly"]');
return button?.getAttribute("aria-pressed") === "true";
}, null, { timeout: 5_000 });
await page.locator('.ai3d-direct-view [data-ai3d-action="toggle-annotation"]').click();
await page.waitForFunction(() => {
const button = document.querySelector('.ai3d-direct-view [data-ai3d-action="toggle-annotation"]');
return button?.getAttribute("aria-pressed") === "true"
&& !document.querySelector(".ai3d-annot-mode-overlay")?.classList.contains("is-hidden");
}, null, { timeout: 5_000 });
await page.evaluate((modelPath) => {
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
const store = plugin?.ps?.store;
const state = store?.getState?.();
if (!store || !state) {
throw new Error("AI Model Workbench store was unavailable");
}
const currentProfiles = state.modelAssetProfiles ?? {};
const existing = currentProfiles[modelPath] ?? {
tags: [],
notes: "",
annotations: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.setState({
modelAssetProfiles: {
...currentProfiles,
[modelPath]: {
...existing,
annotations: [
...(existing.annotations ?? []),
{
id: "verify-focus-pin",
position: [1.0, 1.0, 1.0],
label: "Verification focus",
color: "#2ec4ff",
createdAt: new Date().toISOString(),
headingRef: "Verification focus",
},
],
updatedAt: new Date().toISOString(),
},
},
});
}, workbenchModelVaultPath);
// Ensure canvas changes by toggling wireframe, since focus/disassembly/annotation
// may not alter the rendered pixels for this model.
await page.locator('.ai3d-direct-view [data-ai3d-action="toggle-wireframe"]').click();
await page.waitForTimeout(400);
const after = await page.evaluate(() => {
const canvas = document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas");
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error("Direct workbench canvas missing");
}
return canvas.toDataURL("image/png");
});
await page.evaluate(async (modelPath) => {
const file = window.app?.vault?.getAbstractFileByPath?.(modelPath);
if (!file) {
throw new Error(`Missing workbench model: ${modelPath}`);
}
await window.__ai3dVerifyOpenFile(file);
}, workbenchModelVaultPath);
await page.waitForFunction(() => {
return document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas")
&& document.querySelector(".ai3d-direct-view .ai3d-helper-toolbar");
}, null, { timeout: 20_000 });
await page.locator('.ai3d-direct-view [data-ai3d-action="generate-note"]:visible').last().click();
await page.waitForFunction(() => {
return window.app?.workspace?.getActiveFile?.()?.path === "Analysis/3D Reports/rubiks-cube-3x3 Report.md";
}, null, { timeout: 10_000 });
const noteText = await page.evaluate(async () => {
const file = window.app?.workspace?.getActiveFile?.();
if (!file) return "";
return window.app.vault.read(file);
});
assert(noteText.includes("Geometry overview"), "Generated knowledge note is missing geometry content");
assert(noteText.includes("annotation_count"), "Generated knowledge note is missing annotation metadata");
assert(noteText.includes("## Evidence Snapshots"), "Generated knowledge note is missing evidence snapshot section");
assert(noteText.includes("## Editable Draft"), "Generated knowledge note is missing editable draft section");
assert(noteText.includes("Source: local evidence draft"), "Generated knowledge note should use local draft by default");
assert(noteText.includes("## Local Draft Metadata"), "Generated knowledge note is missing local draft metadata section");
assert(noteText.includes("## Knowledge Index"), "Generated knowledge note is missing knowledge index section");
assert(noteText.includes("## Part Candidates"), "Generated knowledge note is missing part candidates section");
assert(noteText.includes("## Knowledge Nodes"), "Generated knowledge note is missing knowledge nodes section");
assert(noteText.includes("## Annotation Links"), "Generated knowledge note is missing annotation links section");
assert(noteText.includes("## Suggested Part Notes"), "Generated knowledge note is missing suggested part notes section");
assert(noteText.includes("## AI Drafting Input"), "Generated knowledge note is missing AI drafting input section");
assert(noteText.includes("## Remote Draft"), "Generated knowledge note is missing remote draft section");
const analysis = await page.evaluate(async () => {
const file = window.app?.vault?.getAbstractFileByPath?.("Analysis/3D Reports/rubiks-cube-3x3 Analysis.json");
if (!file) return null;
return JSON.parse(await window.app.vault.read(file));
});
assert(analysis?.parts?.length > 0, "Generated analysis sidecar is missing parts");
assert(analysis?.knowledgeNodes?.length > 0, "Generated analysis sidecar is missing knowledge nodes");
assert(analysis?.previewImages?.length > 0, "Generated analysis sidecar is missing preview images");
assert(analysis?.partNotePaths?.length > 0, "Generated analysis sidecar is missing generated part note paths");
assert(typeof analysis?.knowledgeIndexPath === "string", "Generated analysis sidecar is missing knowledge index path");
assert(analysis?.annotationLinks?.length > 0, "Generated analysis sidecar is missing annotation links");
assert(analysis?.draftingInput?.partCandidates?.length > 0, "Generated analysis sidecar is missing drafting input part candidates");
assert(analysis.draftingInput.partCandidates.some((part) => typeof part.notePath === "string"), "Drafting input part candidates should include generated note paths");
assert(analysis?.draftingInput?.annotationLinks?.length > 0, "Generated analysis sidecar is missing drafting input annotation links");
assert(analysis?.localDraft?.sections?.length > 0, "Generated analysis sidecar is missing local draft sections");
assert(analysis?.localDraft?.nextActions?.length > 0, "Generated analysis sidecar is missing local draft next actions");
assert(analysis.draftingInput.evidence.rawModelIncluded === false, "Drafting input should not include raw model data");
assert(analysis.pipeline?.some((stage) => stage.stage === "draft" && stage.status === "success"), "Local analysis should record successful draft stage");
assert(analysis.pipeline?.some((stage) => stage.stage === "partNotes" && stage.status === "success"), "Local analysis should record successful part note stage");
assert(analysis.pipeline?.some((stage) => stage.stage === "index" && stage.status === "success"), "Local analysis should record successful index stage");
assert(analysis.pipeline?.some((stage) => stage.stage === "remoteDraft" && stage.status === "skipped"), "Local analysis should record skipped remote draft stage");
for (const imagePath of analysis.previewImages) {
const imageExists = await page.evaluate((path) => !!window.app?.vault?.getAbstractFileByPath?.(path), imagePath);
assert(imageExists, `Generated evidence image is missing: ${imagePath}`);
}
for (const partNotePath of analysis.partNotePaths) {
const partNoteText = await page.evaluate(async (path) => {
const file = window.app?.vault?.getAbstractFileByPath?.(path);
return file ? window.app.vault.read(file) : null;
}, partNotePath);
assert(partNoteText, `Generated part note is missing: ${partNotePath}`);
assert(partNoteText.includes("## Evidence"), `Generated part note is missing evidence section: ${partNotePath}`);
assert(partNoteText.includes("Parent report"), `Generated part note is missing parent report link: ${partNotePath}`);
}
const knowledgeIndexText = await page.evaluate(async (path) => {
const file = window.app?.vault?.getAbstractFileByPath?.(path);
return file ? window.app.vault.read(file) : null;
}, analysis.knowledgeIndexPath);
assert(knowledgeIndexText, `Generated knowledge index is missing: ${analysis.knowledgeIndexPath}`);
assert(knowledgeIndexText.includes("## Entry Points"), "Generated knowledge index is missing entry points");
assert(knowledgeIndexText.includes("## Part Notes"), "Generated knowledge index is missing part notes");
assert(knowledgeIndexText.includes("<!-- AI3D_INDEX_START -->"), "Generated knowledge index is missing managed start marker");
assert(knowledgeIndexText.includes("<!-- AI3D_INDEX_END -->"), "Generated knowledge index is missing managed end marker");
const preservedLine = "- User preserved index note";
await page.evaluate(async ({ path, line }) => {
const file = window.app?.vault?.getAbstractFileByPath?.(path);
if (!file) {
throw new Error(`Missing generated index for preservation check: ${path}`);
}
const content = await window.app.vault.read(file);
await window.app.vault.modify(file, content.replace("## User Notes\n\n- ", `## User Notes\n\n${line}\n- `));
}, { path: analysis.knowledgeIndexPath, line: preservedLine });
await page.evaluate(async (modelPath) => {
const file = window.app?.vault?.getAbstractFileByPath?.(modelPath);
if (!file) {
throw new Error(`Missing workbench model: ${modelPath}`);
}
await window.__ai3dVerifyOpenFile(file);
}, workbenchModelVaultPath);
await page.waitForFunction(() => {
return document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas")
&& document.querySelector(".ai3d-direct-view .ai3d-helper-toolbar");
}, null, { timeout: 20_000 });
await page.locator('.ai3d-direct-view [data-ai3d-action="generate-note"]:visible').last().click();
await page.waitForFunction(() => {
return window.app?.workspace?.getActiveFile?.()?.path === "Analysis/3D Reports/rubiks-cube-3x3 Report.md";
}, null, { timeout: 10_000 });
const regeneratedIndexText = await page.evaluate(async (path) => {
const file = window.app?.vault?.getAbstractFileByPath?.(path);
return file ? window.app.vault.read(file) : null;
}, analysis.knowledgeIndexPath);
assert(regeneratedIndexText?.includes(preservedLine), "Regenerated knowledge index did not preserve user notes");
assert(regeneratedIndexText.includes("<!-- AI3D_INDEX_START -->"), "Regenerated knowledge index lost managed start marker");
assert(regeneratedIndexText.includes("## Part Notes"), "Regenerated knowledge index lost managed part notes section");
await page.evaluate(async (modelPath) => {
const file = window.app?.vault?.getAbstractFileByPath?.(modelPath);
if (!file) {
throw new Error(`Missing workbench model: ${modelPath}`);
}
await window.__ai3dVerifyOpenFile(file);
}, workbenchModelVaultPath);
await page.waitForFunction(() => {
return document.querySelector(".ai3d-direct-view .ai3d-preview-host canvas")
&& document.querySelector(".ai3d-direct-view .ai3d-helper-toolbar");
}, null, { timeout: 20_000 });
await page.waitForFunction(() => {
return Array.from(document.querySelectorAll('.ai3d-direct-view [data-ai3d-action="open-index"]'))
.some((button) => button instanceof HTMLButtonElement
&& !button.disabled
&& button.offsetParent !== null);
}, null, { timeout: 10_000 });
await page.locator('.ai3d-direct-view [data-ai3d-action="open-index"]:visible').last().click();
await page.waitForFunction((expectedPath) => {
return window.app?.workspace?.getActiveFile?.()?.path === expectedPath;
}, analysis.knowledgeIndexPath, { timeout: 10_000 });
const diagnosticsReport = await page.evaluate(async ({ modelPath, secretUrl }) => {
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
const store = plugin?.ps?.store;
const state = store?.getState?.();
if (!store || !state) {
throw new Error("AI Model Workbench store was unavailable for diagnostics");
}
if (!window.app?.commands?.commands?.["ai-model-workbench:copy-diagnostics-report"]) {
throw new Error("Diagnostics command is not registered");
}
const originalSettings = state.settings;
store.setState({
settings: {
...originalSettings,
analysisMode: "hybrid",
serviceBaseUrl: secretUrl,
freecadCommand: "/private/freecad",
obj2gltfCommand: "/private/obj2gltf",
fbx2gltfCommand: "/private/fbx2gltf",
assimpCommand: "/private/python",
freecadcmdCommand: "/private/freecadcmd",
},
currentModelPath: modelPath,
});
const clipboard = navigator.clipboard;
const writes = [];
const originalDescriptor = Object.getOwnPropertyDescriptor(clipboard, "writeText");
const originalWriteText = clipboard.writeText.bind(clipboard);
Object.defineProperty(clipboard, "writeText", {
configurable: true,
value: async (text) => {
writes.push(String(text));
},
});
try {
window.app.commands.executeCommandById("ai-model-workbench:copy-diagnostics-report");
const deadline = Date.now() + 5000;
while (Date.now() < deadline && writes.length === 0) {
await new Promise((resolve) => window.setTimeout(resolve, 50));
}
return writes.at(-1) ?? "";
} finally {
if (originalDescriptor) {
Object.defineProperty(clipboard, "writeText", originalDescriptor);
} else {
Object.defineProperty(clipboard, "writeText", {
configurable: true,
value: originalWriteText,
});
}
store.setState({ settings: originalSettings });
}
}, {
modelPath: workbenchModelVaultPath,
secretUrl: "https://diagnostics.example.invalid/draft?token=placeholder",
});
assert(diagnosticsReport.includes("# AI Model Workbench Diagnostics"), "Diagnostics report title missing");
assert(diagnosticsReport.includes("Knowledge index: set"), "Diagnostics report is missing knowledge index status");
assert(diagnosticsReport.includes("Knowledge index: set (<redacted .md>)"), "Diagnostics report is missing redacted index status");
assert(!diagnosticsReport.includes(analysis.knowledgeIndexPath), "Diagnostics report leaked generated index path");
assert(!diagnosticsReport.includes(workbenchModelVaultPath), "Diagnostics report leaked current model path");
assert(diagnosticsReport.includes("Last generation: success"), "Diagnostics report is missing last generation state");
assert(diagnosticsReport.includes("service configured"), "Diagnostics report is missing remote service configured status");
assert(!diagnosticsReport.includes("diagnostics.example.invalid"), "Diagnostics report leaked draft service host");
assert(!diagnosticsReport.includes("token=placeholder"), "Diagnostics report leaked draft service token");
assert(!diagnosticsReport.includes("/private/"), "Diagnostics report leaked converter command path");
const fallbackIndexPath = await page.evaluate(async ({ modelPath, expectedPath }) => {
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
const store = plugin?.ps?.store;
if (!store || !window.app?.commands?.commands?.["ai-model-workbench:open-knowledge-index"]) {
throw new Error("AI Model Workbench open index command was unavailable");
}
const state = store.getState();
store.setState({
currentModelPath: null,
lastKnowledgeGeneration: {
modelPath,
reportNotePath: "Analysis/3D Reports/rubiks-cube-3x3 Report.md",
analysisSidecarPath: "Analysis/3D Reports/rubiks-cube-3x3 Analysis.json",
knowledgeIndexPath: expectedPath,
partNoteCount: 1,
previewImageCount: 1,
generatedAt: new Date().toISOString(),
status: "success",
warningCount: 0,
},
});
try {
window.app.commands.executeCommandById("ai-model-workbench:open-knowledge-index");
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const activePath = window.app?.workspace?.getActiveFile?.()?.path;
if (activePath === expectedPath) {
return activePath;
}
await new Promise((resolve) => window.setTimeout(resolve, 50));
}
return window.app?.workspace?.getActiveFile?.()?.path ?? null;
} finally {
store.setState({
currentModelPath: modelPath,
lastKnowledgeGeneration: state.lastKnowledgeGeneration,
});
}
}, {
modelPath: workbenchModelVaultPath,
expectedPath: analysis.knowledgeIndexPath,
});
assert(fallbackIndexPath === analysis.knowledgeIndexPath, `Open knowledge index fallback failed: ${fallbackIndexPath}`);
const directViewResult = await page.evaluate(({ beforeDataUrl, afterDataUrl, analysisPartCount }) => {
const host = document.querySelector(".ai3d-direct-view .ai3d-preview-host");
const panel = document.querySelector(".ai3d-direct-workbench-panel");
const actions = Array.from(document.querySelectorAll(".ai3d-direct-view [data-ai3d-action]"))
.map((element) => element.getAttribute("data-ai3d-action"))
.filter(Boolean);
return {
backend: host?.getAttribute("data-ai3d-backend"),
routeReason: host?.getAttribute("data-ai3d-route-reason"),
actionCount: actions.length,
hasFocus: actions.includes("toggle-focus"),
hasDisassembly: actions.includes("toggle-disassembly"),
hasAnnotation: actions.includes("toggle-annotation"),
hasPanel: !!panel,
hasExplodeControl: actions.includes("set-explode"),
hasKnowledgeAction: actions.includes("generate-note"),
hasOpenIndexAction: actions.includes("open-index"),
hasDiagnosticsCommand: !!window.app?.commands?.commands?.["ai-model-workbench:copy-diagnostics-report"],
canvasChangedAfterControls: beforeDataUrl !== afterDataUrl,
activeFile: window.app?.workspace?.getActiveFile?.()?.path ?? null,
analysisPartCount,
};
}, { beforeDataUrl: before, afterDataUrl: after, analysisPartCount: analysis.parts.length });
assert(directViewResult.hasPanel, "Direct workbench panel did not render");
assert(!directViewResult.hasExplodeControl, "Direct workbench panel still exposes explode control");
assert(directViewResult.hasKnowledgeAction, "Direct workbench panel is missing knowledge action");
assert(directViewResult.hasOpenIndexAction, "Direct workbench panel is missing open index action");
assert(directViewResult.hasDiagnosticsCommand, "Diagnostics command is missing");
assert(directViewResult.activeFile === analysis.knowledgeIndexPath, `Open index action did not activate the index: ${directViewResult.activeFile}`);
assert(directViewResult.canvasChangedAfterControls, "Direct workbench controls did not change the rendered canvas");
return directViewResult;
}
await registerVault();
await prepareVault();
await openObsidian();
await verifyPage();
if (shouldClean) {
await cleanupVault();
}