mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 17:30:25 +00:00
- Delete the reset disassembled parts button from the inline preview toolbar. - Remove unused reset-parts i18n keys and verify:obsidian step.
885 lines
38 KiB
JavaScript
885 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(analysis.knowledgeIndexPath), "Diagnostics report is missing generated index 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();
|
|
}
|