first commit
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultTestRoots = [
|
||||
path.join(packageRoot, "src"),
|
||||
path.join(packageRoot, "scripts"),
|
||||
];
|
||||
|
||||
function collectTests(dir, tests = []) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return tests;
|
||||
}
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTests(entryPath, tests);
|
||||
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
|
||||
tests.push(entryPath);
|
||||
}
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
|
||||
const tests = (requestedTests.length ? requestedTests : defaultTestRoots.flatMap((root) => collectTests(root)))
|
||||
.sort();
|
||||
|
||||
if (!tests.length) {
|
||||
console.error("No CAD Viewer tests found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--test",
|
||||
"--experimental-default-type=module",
|
||||
...tests,
|
||||
], {
|
||||
cwd: packageRoot,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
DEFAULT_VIEWER_PORT,
|
||||
VIEWER_SERVER_API_VERSION,
|
||||
VIEWER_SERVER_APP_ID,
|
||||
} from "../src/server/viewerServerInfo.mjs";
|
||||
import {
|
||||
readViewerServerRegistry,
|
||||
} from "../src/server/viewerServerRegistry.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const defaultPackageRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const startModeFlag = "--viewer-start-mode";
|
||||
const defaultRootDirFlag = "--dir";
|
||||
const startModes = new Set(["auto", "dev", "serve"]);
|
||||
const defaultAgentHost = "127.0.0.1";
|
||||
const defaultPortScanLimit = 64;
|
||||
const probeTimeoutMs = 350;
|
||||
const activationTimeoutMs = 30_000;
|
||||
const directoryActivationFeature = "directory-activation";
|
||||
|
||||
function requiredValue(argv, index, flag) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeStartMode(value, flag = startModeFlag) {
|
||||
const mode = String(value || "").trim();
|
||||
if (!startModes.has(mode)) {
|
||||
throw new Error(`${flag} must be one of: auto, dev, serve.`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function parseAgentPort(value, flag = "--port") {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
throw new Error(`${flag} must be a TCP port from 1 to 65535`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, flag) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${flag} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function normalizeAgentDirectory(value, { fsImpl = fs } = {}) {
|
||||
const rawDir = String(value || "").trim();
|
||||
if (!rawDir) {
|
||||
throw new Error("agent:start requires --dir <absolute-directory>");
|
||||
}
|
||||
if (!path.isAbsolute(rawDir)) {
|
||||
throw new Error("agent:start --dir must be an absolute path");
|
||||
}
|
||||
const resolvedDir = path.resolve(rawDir);
|
||||
let stats = null;
|
||||
try {
|
||||
stats = fsImpl.statSync(resolvedDir);
|
||||
} catch {
|
||||
throw new Error(`agent:start --dir directory not found: ${resolvedDir}`);
|
||||
}
|
||||
if (!stats?.isDirectory?.()) {
|
||||
throw new Error(`agent:start --dir is not a directory: ${resolvedDir}`);
|
||||
}
|
||||
return resolvedDir;
|
||||
}
|
||||
|
||||
export function replaceForwardedDefaultRootDir(argv = [], rootDir) {
|
||||
const normalizedRootDir = String(rootDir || "").trim();
|
||||
const nextArgs = [];
|
||||
let replaced = false;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
|
||||
nextArgs.push(`${defaultRootDirFlag}=${normalizedRootDir}`);
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === defaultRootDirFlag) {
|
||||
nextArgs.push(arg, normalizedRootDir);
|
||||
replaced = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
nextArgs.push(arg);
|
||||
}
|
||||
if (!replaced) {
|
||||
nextArgs.push(defaultRootDirFlag, normalizedRootDir);
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
export function parseAgentStartArgs(argv = [], { fsImpl = fs } = {}) {
|
||||
const options = {
|
||||
startMode: "auto",
|
||||
forwardedArgs: [],
|
||||
directory: "",
|
||||
portScanLimit: defaultPortScanLimit,
|
||||
jsonResult: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith(`${startModeFlag}=`)) {
|
||||
options.startMode = normalizeStartMode(arg.slice(startModeFlag.length + 1));
|
||||
continue;
|
||||
}
|
||||
if (arg === startModeFlag) {
|
||||
options.startMode = normalizeStartMode(requiredValue(argv, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--port-scan-limit=")) {
|
||||
options.portScanLimit = parsePositiveInteger(arg.slice("--port-scan-limit=".length), "--port-scan-limit");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--port-scan-limit") {
|
||||
options.portScanLimit = parsePositiveInteger(requiredValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--shutdown-after=") || arg === "--shutdown-after") {
|
||||
throw new Error("agent:start does not support --shutdown-after");
|
||||
}
|
||||
if (arg === "--json") {
|
||||
options.jsonResult = true;
|
||||
continue;
|
||||
}
|
||||
options.forwardedArgs.push(arg);
|
||||
}
|
||||
|
||||
options.directory = normalizeAgentDirectory(forwardedDefaultRootDir(options.forwardedArgs), { fsImpl });
|
||||
options.forwardedArgs = replaceForwardedDefaultRootDir(options.forwardedArgs, options.directory);
|
||||
return options;
|
||||
}
|
||||
|
||||
export function forwardedDefaultRootDir(argv = []) {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
|
||||
return arg.slice(defaultRootDirFlag.length + 1).trim();
|
||||
}
|
||||
if (arg === defaultRootDirFlag) {
|
||||
return requiredValue(argv, index, arg).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function stripDefaultRootDirArgs(argv = []) {
|
||||
const stripped = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
|
||||
continue;
|
||||
}
|
||||
if (arg === defaultRootDirFlag) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
stripped.push(arg);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function forwardedServerTarget(argv = []) {
|
||||
const target = {
|
||||
host: defaultAgentHost,
|
||||
port: DEFAULT_VIEWER_PORT,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith("--host=")) {
|
||||
target.host = arg.slice("--host=".length).trim() || defaultAgentHost;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--host") {
|
||||
target.host = requiredValue(argv, index, arg).trim() || defaultAgentHost;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--port=")) {
|
||||
target.port = parseAgentPort(arg.slice("--port=".length), "--port");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--port") {
|
||||
target.port = parseAgentPort(requiredValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export function replaceForwardedPort(argv = [], port = DEFAULT_VIEWER_PORT) {
|
||||
const normalizedPort = parseAgentPort(port, "--port");
|
||||
const nextArgs = [];
|
||||
let replaced = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg.startsWith("--port=")) {
|
||||
nextArgs.push(`--port=${normalizedPort}`);
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--port") {
|
||||
nextArgs.push(arg, String(normalizedPort));
|
||||
replaced = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
nextArgs.push(arg);
|
||||
}
|
||||
|
||||
if (!replaced) {
|
||||
nextArgs.push("--port", String(normalizedPort));
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
export function isSymlinkPath(filePath) {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.lstatSync(filePath).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectAgentStartMode({
|
||||
requestedMode = "auto",
|
||||
npmConfigPrefix = "",
|
||||
npmPackageJson = "",
|
||||
} = {}) {
|
||||
const mode = normalizeStartMode(requestedMode);
|
||||
if (mode !== "auto") {
|
||||
return mode;
|
||||
}
|
||||
const packagePrefix = npmConfigPrefix
|
||||
|| (npmPackageJson ? path.dirname(npmPackageJson) : "")
|
||||
|| process.env.npm_config_prefix
|
||||
|| (process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : "");
|
||||
return isSymlinkPath(packagePrefix) ? "dev" : "serve";
|
||||
}
|
||||
|
||||
function safeRealpath(filePath) {
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
try {
|
||||
return fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function gitOutput(args, cwd) {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(rawGitDir, cwd) {
|
||||
if (!rawGitDir) {
|
||||
return "";
|
||||
}
|
||||
return safeRealpath(path.isAbsolute(rawGitDir)
|
||||
? rawGitDir
|
||||
: path.resolve(cwd, rawGitDir));
|
||||
}
|
||||
|
||||
function normalizeGitBranch(branch) {
|
||||
const normalizedBranch = String(branch || "").trim();
|
||||
return normalizedBranch && normalizedBranch !== "HEAD" ? normalizedBranch : "detached";
|
||||
}
|
||||
|
||||
export function buildAgentViewerGit({
|
||||
env = process.env,
|
||||
cwd = process.cwd(),
|
||||
} = {}) {
|
||||
const resolvedCwd = safeRealpath(env.INIT_CWD || cwd);
|
||||
const gitDir = resolveGitDir(gitOutput(["rev-parse", "--git-dir"], resolvedCwd), resolvedCwd);
|
||||
if (!gitDir) {
|
||||
return "";
|
||||
}
|
||||
const gitBranch = gitOutput(["symbolic-ref", "--quiet", "--short", "HEAD"], resolvedCwd)
|
||||
|| gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], resolvedCwd);
|
||||
return `${gitDir}#${normalizeGitBranch(gitBranch)}`;
|
||||
}
|
||||
|
||||
function envWithGit(env, git) {
|
||||
return {
|
||||
...env,
|
||||
VIEWER_GIT: String(git || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function envWithAgentStartMode(env, mode) {
|
||||
return {
|
||||
...env,
|
||||
VIEWER_AGENT_START_MODE: normalizeStartMode(mode),
|
||||
};
|
||||
}
|
||||
|
||||
function envWithGitAndMode(env, git, mode) {
|
||||
return envWithAgentStartMode(envWithGit(env, git), mode);
|
||||
}
|
||||
|
||||
function envWithGitModeAndDefaultDir(env, git, mode, defaultDir = "") {
|
||||
const nextEnv = envWithGitAndMode(env, git, mode);
|
||||
const normalizedDir = String(defaultDir || "").trim();
|
||||
if (normalizedDir) {
|
||||
nextEnv.VIEWER_DEFAULT_DIR = normalizedDir;
|
||||
}
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
export function readAgentViewerPackageVersion(packageRoot = defaultPackageRoot, { fsImpl = fs } = {}) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fsImpl.readFileSync(path.join(path.resolve(packageRoot), "package.json"), "utf8"));
|
||||
return String(packageJson.version || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentStartCommand({
|
||||
mode,
|
||||
packageRoot = defaultPackageRoot,
|
||||
forwardedArgs = [],
|
||||
env = process.env,
|
||||
nodePath = process.execPath,
|
||||
git = buildAgentViewerGit({ env }),
|
||||
} = {}) {
|
||||
const resolvedPackageRoot = path.resolve(packageRoot);
|
||||
if (mode === "dev") {
|
||||
const nextEnv = envWithGitModeAndDefaultDir(env, git, mode, forwardedDefaultRootDir(forwardedArgs));
|
||||
return {
|
||||
command: nodePath,
|
||||
args: [
|
||||
path.join(resolvedPackageRoot, "node_modules", "vite", "bin", "vite.js"),
|
||||
"dev",
|
||||
...stripDefaultRootDirArgs(forwardedArgs),
|
||||
],
|
||||
cwd: resolvedPackageRoot,
|
||||
env: nextEnv,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: nodePath,
|
||||
args: [
|
||||
path.join(resolvedPackageRoot, "src", "server", "server.mjs"),
|
||||
...forwardedArgs,
|
||||
],
|
||||
cwd: resolvedPackageRoot,
|
||||
env: envWithGitAndMode(env, git, mode),
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentStartCommand({
|
||||
argv = process.argv.slice(2),
|
||||
env = process.env,
|
||||
packageRoot = defaultPackageRoot,
|
||||
nodePath = process.execPath,
|
||||
git = buildAgentViewerGit({ env }),
|
||||
} = {}) {
|
||||
const parsed = parseAgentStartArgs(argv);
|
||||
const mode = selectAgentStartMode({
|
||||
requestedMode: parsed.startMode,
|
||||
npmConfigPrefix: env.npm_config_prefix,
|
||||
npmPackageJson: env.npm_package_json,
|
||||
});
|
||||
return buildAgentStartCommand({
|
||||
mode,
|
||||
packageRoot,
|
||||
forwardedArgs: parsed.forwardedArgs,
|
||||
env,
|
||||
nodePath,
|
||||
git,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(host, port) {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
export function agentViewerUrl(baseUrl, directory) {
|
||||
const url = new URL("/", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`);
|
||||
url.searchParams.set("dir", normalizeAgentDirectory(directory));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeReuseMode(mode) {
|
||||
const normalizedMode = String(mode || "").trim();
|
||||
if (normalizedMode === "dev") {
|
||||
return "dev";
|
||||
}
|
||||
if (normalizedMode === "serve" || normalizedMode === "dist" || normalizedMode === "bundle") {
|
||||
return "serve";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeReuseContext(contextOrGit = {}) {
|
||||
if (typeof contextOrGit === "string") {
|
||||
return {
|
||||
git: contextOrGit,
|
||||
mode: "dev",
|
||||
viewerVersion: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
git: String(contextOrGit?.git || ""),
|
||||
mode: normalizeReuseMode(contextOrGit?.mode),
|
||||
viewerVersion: String(contextOrGit?.viewerVersion || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function serverInfoMode(serverInfo) {
|
||||
return normalizeReuseMode(serverInfo?.serverMode || serverInfo?.runtimeMode || serverInfo?.mode);
|
||||
}
|
||||
|
||||
function serverInfoRequiresGitMatch(serverInfo, context) {
|
||||
const currentMode = normalizeReuseMode(context?.mode);
|
||||
const serverMode = serverInfoMode(serverInfo);
|
||||
return !currentMode || !serverMode || currentMode === "dev" || serverMode === "dev";
|
||||
}
|
||||
|
||||
function serverInfoGitAllowsReuse(serverInfo, context) {
|
||||
if (!serverInfoRequiresGitMatch(serverInfo, context)) {
|
||||
return true;
|
||||
}
|
||||
const currentGit = String(context?.git || "");
|
||||
const serverGit = String(serverInfo?.git || "");
|
||||
return !currentGit || !serverGit || currentGit === serverGit;
|
||||
}
|
||||
|
||||
function serverInfoVersionAllowsReuse(serverInfo, context) {
|
||||
if (serverInfoRequiresGitMatch(serverInfo, context)) {
|
||||
return true;
|
||||
}
|
||||
const currentVersion = String(context?.viewerVersion || "").trim();
|
||||
const serverVersion = String(serverInfo?.viewerVersion || "").trim();
|
||||
return Boolean(currentVersion && serverVersion && currentVersion === serverVersion);
|
||||
}
|
||||
|
||||
function serverInfoHasFeature(serverInfo, feature) {
|
||||
const features = Array.isArray(serverInfo?.serverFeatures) ? serverInfo.serverFeatures : [];
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
export function isReusableAgentViewerServer(serverInfo, contextOrGit = {}) {
|
||||
const context = normalizeReuseContext(contextOrGit);
|
||||
return Boolean(
|
||||
serverInfo &&
|
||||
serverInfo.app === VIEWER_SERVER_APP_ID &&
|
||||
Number(serverInfo.serverApiVersion || 0) >= VIEWER_SERVER_API_VERSION &&
|
||||
serverInfo.dynamicRoot === true &&
|
||||
serverInfoHasFeature(serverInfo, directoryActivationFeature) &&
|
||||
serverInfoGitAllowsReuse(serverInfo, context) &&
|
||||
serverInfoVersionAllowsReuse(serverInfo, context)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(fetchImpl, url, optionsOrTimeoutMs = {}, timeoutMs = probeTimeoutMs) {
|
||||
const requestOptions = typeof optionsOrTimeoutMs === "number" ? {} : { ...optionsOrTimeoutMs };
|
||||
const effectiveTimeoutMs = typeof optionsOrTimeoutMs === "number" ? optionsOrTimeoutMs : timeoutMs;
|
||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||
const timeout = controller
|
||||
? setTimeout(() => controller.abort(), effectiveTimeoutMs)
|
||||
: null;
|
||||
try {
|
||||
return await fetchImpl(url, controller ? { ...requestOptions, signal: controller.signal } : requestOptions);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function probeBlockedByPermissions(error) {
|
||||
const code = String(error?.cause?.code || error?.code || "");
|
||||
return code === "EPERM" || code === "EACCES";
|
||||
}
|
||||
|
||||
export async function probeAgentViewerPort({
|
||||
host = defaultAgentHost,
|
||||
port = DEFAULT_VIEWER_PORT,
|
||||
fetchImpl = globalThis.fetch,
|
||||
timeoutMs = probeTimeoutMs,
|
||||
} = {}) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return { status: "unknown", port, error: "fetch is unavailable" };
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(host, port);
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchImpl, `${baseUrl}/__cad/server`, timeoutMs);
|
||||
if (!response?.ok) {
|
||||
return { status: "occupied", port, baseUrl };
|
||||
}
|
||||
try {
|
||||
const serverInfo = await response.json();
|
||||
if (serverInfo?.app === VIEWER_SERVER_APP_ID) {
|
||||
return { status: "viewer", port, baseUrl, serverInfo };
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON responses mean another process is using this port.
|
||||
}
|
||||
return { status: "occupied", port, baseUrl };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return { status: "occupied", port, baseUrl, error };
|
||||
}
|
||||
if (probeBlockedByPermissions(error)) {
|
||||
return { status: "blocked", port, baseUrl, error };
|
||||
}
|
||||
return { status: "closed", port, baseUrl, error };
|
||||
}
|
||||
}
|
||||
|
||||
async function responseSnippet(response) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
return String(text || "").trim().slice(0, 500);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateAgentViewerDirectory({
|
||||
baseUrl,
|
||||
directory,
|
||||
fetchImpl = globalThis.fetch,
|
||||
timeoutMs = activationTimeoutMs,
|
||||
} = {}) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("fetch is unavailable; cannot activate CAD Viewer directory");
|
||||
}
|
||||
const normalizedDirectory = normalizeAgentDirectory(directory);
|
||||
const activationUrl = new URL("/__cad/directory/activate", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`);
|
||||
activationUrl.searchParams.set("dir", normalizedDirectory);
|
||||
let response = null;
|
||||
try {
|
||||
response = await fetchWithTimeout(fetchImpl, String(activationUrl), {
|
||||
method: "POST",
|
||||
}, timeoutMs);
|
||||
} catch (error) {
|
||||
if (probeBlockedByPermissions(error)) {
|
||||
throw new Error(`CAD Viewer directory activation was blocked for ${baseUrl}; rerun agent:start with local network permission.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
const detail = response ? await responseSnippet(response) : "";
|
||||
const status = response ? `${response.status || "unknown"} ${response.statusText || ""}`.trim() : "unknown";
|
||||
throw new Error(`Failed to activate CAD Viewer directory ${normalizedDirectory}: ${status}${detail ? ` - ${detail}` : ""}`);
|
||||
}
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new Error(`CAD Viewer directory activation did not return JSON for ${normalizedDirectory}`);
|
||||
}
|
||||
if (!payload?.ok) {
|
||||
throw new Error(`CAD Viewer directory activation failed for ${normalizedDirectory}`);
|
||||
}
|
||||
return {
|
||||
directory: normalizedDirectory,
|
||||
viewerUrl: agentViewerUrl(baseUrl, normalizedDirectory),
|
||||
activeDirectory: payload.directory || null,
|
||||
serverInfo: payload.server || null,
|
||||
};
|
||||
}
|
||||
|
||||
function registryHost(serverInfo, fallbackHost) {
|
||||
try {
|
||||
const url = new URL(String(serverInfo?.url || ""));
|
||||
return url.hostname || fallbackHost;
|
||||
} catch {
|
||||
return fallbackHost;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAgentViewerPort({
|
||||
forwardedArgs = [],
|
||||
git = "",
|
||||
mode = "dev",
|
||||
viewerVersion = "",
|
||||
registryServers = readViewerServerRegistry(),
|
||||
probePort = probeAgentViewerPort,
|
||||
portScanLimit = defaultPortScanLimit,
|
||||
} = {}) {
|
||||
const target = forwardedServerTarget(forwardedArgs);
|
||||
const reuseContext = { git, mode, viewerVersion };
|
||||
const reusableRegistryServers = registryServers
|
||||
.filter((serverInfo) => isReusableAgentViewerServer(serverInfo, reuseContext))
|
||||
.sort((left, right) => Number(left.port) - Number(right.port));
|
||||
for (const serverInfo of reusableRegistryServers) {
|
||||
const host = registryHost(serverInfo, target.host);
|
||||
const probe = await probePort({ host, port: serverInfo.port });
|
||||
if (probe.status === "blocked") {
|
||||
throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`);
|
||||
}
|
||||
if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) {
|
||||
return {
|
||||
action: "reuse",
|
||||
host,
|
||||
port: Number(serverInfo.port),
|
||||
baseUrl: probe.baseUrl,
|
||||
serverInfo: probe.serverInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < portScanLimit; offset += 1) {
|
||||
const port = target.port + offset;
|
||||
if (port > 65535) {
|
||||
break;
|
||||
}
|
||||
const probe = await probePort({ host: target.host, port });
|
||||
if (probe.status === "blocked") {
|
||||
throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`);
|
||||
}
|
||||
if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) {
|
||||
return {
|
||||
action: "reuse",
|
||||
host: target.host,
|
||||
port,
|
||||
baseUrl: probe.baseUrl,
|
||||
serverInfo: probe.serverInfo,
|
||||
};
|
||||
}
|
||||
if (probe.status === "closed") {
|
||||
return {
|
||||
action: "start",
|
||||
host: target.host,
|
||||
port,
|
||||
baseUrl: probe.baseUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No reusable or free CAD Viewer port found from ${target.port} through ${Math.min(target.port + portScanLimit - 1, 65535)}.`);
|
||||
}
|
||||
|
||||
export async function resolveAgentStartLaunch({
|
||||
argv = process.argv.slice(2),
|
||||
env = process.env,
|
||||
packageRoot = defaultPackageRoot,
|
||||
nodePath = process.execPath,
|
||||
probePort = probeAgentViewerPort,
|
||||
registryServers = readViewerServerRegistry(),
|
||||
} = {}) {
|
||||
const parsed = parseAgentStartArgs(argv);
|
||||
const git = buildAgentViewerGit({ env });
|
||||
const mode = selectAgentStartMode({
|
||||
requestedMode: parsed.startMode,
|
||||
npmConfigPrefix: env.npm_config_prefix,
|
||||
npmPackageJson: env.npm_package_json,
|
||||
});
|
||||
const viewerVersion = readAgentViewerPackageVersion(packageRoot);
|
||||
const portResolution = await resolveAgentViewerPort({
|
||||
forwardedArgs: parsed.forwardedArgs,
|
||||
git,
|
||||
mode,
|
||||
viewerVersion,
|
||||
registryServers,
|
||||
probePort,
|
||||
portScanLimit: parsed.portScanLimit,
|
||||
});
|
||||
if (portResolution.action === "reuse") {
|
||||
return {
|
||||
...portResolution,
|
||||
git,
|
||||
directory: parsed.directory,
|
||||
viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory),
|
||||
jsonResult: parsed.jsonResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
host: portResolution.host,
|
||||
port: portResolution.port,
|
||||
baseUrl: portResolution.baseUrl,
|
||||
git,
|
||||
directory: parsed.directory,
|
||||
viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory),
|
||||
jsonResult: parsed.jsonResult,
|
||||
command: buildAgentStartCommand({
|
||||
mode,
|
||||
packageRoot,
|
||||
forwardedArgs: replaceForwardedPort(parsed.forwardedArgs, portResolution.port),
|
||||
env,
|
||||
nodePath,
|
||||
git,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAgentStart(options = {}) {
|
||||
const launch = await resolveAgentStartLaunch(options);
|
||||
if (launch.action === "reuse") {
|
||||
await activateAgentViewerDirectory({
|
||||
baseUrl: launch.baseUrl,
|
||||
directory: launch.directory,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
console.log(`CAD Viewer already running at ${launch.viewerUrl}`);
|
||||
console.log(`CAD Viewer URL: ${launch.viewerUrl}`);
|
||||
console.log(`CAD Viewer git: ${launch.git || "none"}`);
|
||||
if (launch.jsonResult) {
|
||||
console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "reuse" }));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = launch.command;
|
||||
console.log(`Starting CAD Viewer ${command.mode} server at ${launch.viewerUrl}`);
|
||||
console.log(`CAD Viewer URL: ${launch.viewerUrl}`);
|
||||
console.log(`CAD Viewer git: ${launch.git || "none"}`);
|
||||
if (launch.jsonResult) {
|
||||
console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "start" }));
|
||||
}
|
||||
const child = spawn(command.command, command.args, {
|
||||
cwd: command.cwd,
|
||||
env: command.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`Failed to start CAD Viewer ${command.mode} server: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === scriptPath;
|
||||
if (isMain) {
|
||||
try {
|
||||
await runAgentStart();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
activateAgentViewerDirectory,
|
||||
agentViewerUrl,
|
||||
buildAgentViewerGit,
|
||||
buildAgentStartCommand,
|
||||
forwardedDefaultRootDir,
|
||||
forwardedServerTarget,
|
||||
normalizeAgentDirectory,
|
||||
isReusableAgentViewerServer,
|
||||
parseAgentStartArgs,
|
||||
probeAgentViewerPort,
|
||||
replaceForwardedPort,
|
||||
resolveAgentStartLaunch,
|
||||
resolveAgentStartCommand,
|
||||
resolveAgentViewerPort,
|
||||
selectAgentStartMode,
|
||||
stripDefaultRootDirArgs,
|
||||
} from "./start-agent-viewer.mjs";
|
||||
|
||||
test("parseAgentStartArgs consumes launcher mode and preserves server flags", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
assert.deepEqual(
|
||||
parseAgentStartArgs([
|
||||
"--viewer-start-mode=dev",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--dir",
|
||||
directory,
|
||||
"--port=4178",
|
||||
]),
|
||||
{
|
||||
startMode: "dev",
|
||||
forwardedArgs: [
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--dir",
|
||||
directory,
|
||||
"--port=4178",
|
||||
],
|
||||
directory,
|
||||
portScanLimit: 64,
|
||||
jsonResult: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAgentStartArgs --json sets jsonResult and is not forwarded to the server", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const result = parseAgentStartArgs(["--dir", directory, "--host", "127.0.0.1", "--json"]);
|
||||
assert.equal(result.jsonResult, true);
|
||||
assert.ok(!result.forwardedArgs.includes("--json"), "--json must not be forwarded to the server");
|
||||
});
|
||||
|
||||
test("forwardedDefaultRootDir reads and strips default directory flags", () => {
|
||||
assert.equal(forwardedDefaultRootDir(["--host", "127.0.0.1", "--dir=/project/models"]), "/project/models");
|
||||
assert.equal(forwardedDefaultRootDir(["--dir", "models", "--port", "4178"]), "models");
|
||||
assert.deepEqual(
|
||||
stripDefaultRootDirArgs(["--host", "127.0.0.1", "--dir", "/project/models", "--port=4178"]),
|
||||
["--host", "127.0.0.1", "--port=4178"]
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAgentStartArgs requires agent:start --dir to be an absolute existing directory", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, "not-a-dir");
|
||||
await fs.writeFile(filePath, "");
|
||||
|
||||
assert.equal(normalizeAgentDirectory(directory), directory);
|
||||
assert.throws(() => parseAgentStartArgs(["--host", "127.0.0.1"]), /requires --dir/);
|
||||
assert.throws(() => parseAgentStartArgs(["--dir", "models"]), /absolute path/);
|
||||
assert.throws(() => parseAgentStartArgs(["--dir", path.join(directory, "missing")]), /directory not found/);
|
||||
assert.throws(() => parseAgentStartArgs(["--dir", filePath]), /is not a directory/);
|
||||
});
|
||||
|
||||
test("parseAgentStartArgs rejects invalid launcher modes", () => {
|
||||
assert.throws(
|
||||
() => parseAgentStartArgs(["--viewer-start-mode", "test"]),
|
||||
/must be one of/
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAgentStartArgs rejects shutdown lifetime flags", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
assert.throws(
|
||||
() => parseAgentStartArgs(["--dir", directory, "--shutdown-after", "12h"]),
|
||||
/does not support --shutdown-after/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseAgentStartArgs(["--dir", directory, "--shutdown-after=12h"]),
|
||||
/does not support --shutdown-after/
|
||||
);
|
||||
});
|
||||
|
||||
test("forwardedServerTarget reads host and port flags", () => {
|
||||
assert.deepEqual(
|
||||
forwardedServerTarget(["--host=0.0.0.0", "--port", "4200"]),
|
||||
{
|
||||
host: "0.0.0.0",
|
||||
port: 4200,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceForwardedPort updates or appends the selected port", () => {
|
||||
assert.deepEqual(
|
||||
replaceForwardedPort(["--host", "127.0.0.1", "--port=4178"], 4180),
|
||||
["--host", "127.0.0.1", "--port=4180"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
replaceForwardedPort(["--host", "127.0.0.1"], 4181),
|
||||
["--host", "127.0.0.1", "--port", "4181"]
|
||||
);
|
||||
});
|
||||
|
||||
test("selectAgentStartMode uses dev mode for symlinked npm prefixes", async (t) => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
|
||||
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
|
||||
const realViewer = path.join(tmpDir, "viewer");
|
||||
const linkedViewer = path.join(tmpDir, "skills", "cad-viewer", "scripts", "viewer");
|
||||
await fs.mkdir(realViewer, { recursive: true });
|
||||
await fs.mkdir(path.dirname(linkedViewer), { recursive: true });
|
||||
await fs.symlink(realViewer, linkedViewer, "dir");
|
||||
|
||||
assert.equal(selectAgentStartMode({ npmConfigPrefix: linkedViewer }), "dev");
|
||||
assert.equal(selectAgentStartMode({ npmPackageJson: path.join(linkedViewer, "package.json") }), "dev");
|
||||
assert.equal(selectAgentStartMode({ npmConfigPrefix: realViewer }), "serve");
|
||||
assert.equal(selectAgentStartMode({ requestedMode: "serve", npmConfigPrefix: linkedViewer }), "serve");
|
||||
});
|
||||
|
||||
test("buildAgentViewerGit returns a worktree and branch value when git exists", async (t) => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
|
||||
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
|
||||
const packageRoot = path.join(tmpDir, "viewer");
|
||||
await fs.mkdir(packageRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
||||
execFileSync("git", ["checkout", "-b", "test-branch"], { cwd: tmpDir, stdio: "ignore" });
|
||||
|
||||
const git = buildAgentViewerGit({ env: {}, cwd: packageRoot });
|
||||
assert.match(git, /#test-branch$/);
|
||||
assert.match(git, /\/\.git#test-branch$/);
|
||||
});
|
||||
|
||||
test("buildAgentViewerGit is empty outside git", async (t) => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
|
||||
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
|
||||
assert.equal(buildAgentViewerGit({ env: {}, cwd: tmpDir }), "");
|
||||
});
|
||||
|
||||
test("buildAgentStartCommand prepares dev mode without a server lifetime", () => {
|
||||
const command = buildAgentStartCommand({
|
||||
mode: "dev",
|
||||
packageRoot: "/project/viewer",
|
||||
forwardedArgs: ["--host", "127.0.0.1", "--dir", "/project/models", "--port", "4178"],
|
||||
env: {},
|
||||
nodePath: "/node",
|
||||
git: "git-a",
|
||||
});
|
||||
|
||||
assert.equal(command.command, "/node");
|
||||
assert.deepEqual(command.args, [
|
||||
"/project/viewer/node_modules/vite/bin/vite.js",
|
||||
"dev",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"4178",
|
||||
]);
|
||||
assert.equal(command.env.VIEWER_SERVER_LIFETIME_MS, undefined);
|
||||
assert.equal(command.env.VIEWER_DEFAULT_DIR, "/project/models");
|
||||
assert.equal(command.env.VIEWER_GIT, "git-a");
|
||||
assert.equal(command.env.VIEWER_AGENT_START_MODE, "dev");
|
||||
});
|
||||
|
||||
test("resolveAgentStartCommand keeps server-only flags on the production server path", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const command = resolveAgentStartCommand({
|
||||
argv: ["--viewer-start-mode", "serve", "--dir", directory],
|
||||
env: {},
|
||||
packageRoot: "/project/viewer",
|
||||
nodePath: "/node",
|
||||
});
|
||||
|
||||
assert.equal(command.mode, "serve");
|
||||
assert.equal(command.env.VIEWER_AGENT_START_MODE, "serve");
|
||||
assert.deepEqual(command.args, [
|
||||
"/project/viewer/src/server/server.mjs",
|
||||
"--dir",
|
||||
directory,
|
||||
]);
|
||||
});
|
||||
|
||||
test("isReusableAgentViewerServer uses git only when both sides report it", () => {
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
}, "git-a"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-b",
|
||||
}, "git-a"),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
}, "git-a"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
git: "git-a",
|
||||
}, "git-a"),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
activeDirectories: [{
|
||||
dir: "/project/models",
|
||||
rootPath: "/project/models",
|
||||
}],
|
||||
}, ""),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
directoryRoot: "/project",
|
||||
activeDirectories: [{
|
||||
dir: "/project/models",
|
||||
rootPath: "/project/models",
|
||||
}],
|
||||
}, "git-a"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
activeDirectories: [{
|
||||
dir: "/project/skill",
|
||||
rootPath: "/project/skill",
|
||||
}],
|
||||
}, "git-a"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isReusableAgentViewerServer ignores git for matching dist bundle versions", () => {
|
||||
const distServer = {
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
serverMode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-b",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer(distServer, {
|
||||
mode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-a",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer(distServer, {
|
||||
mode: "serve",
|
||||
viewerVersion: "0.3.3",
|
||||
git: "git-a",
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
...distServer,
|
||||
serverMode: "dev",
|
||||
}, {
|
||||
mode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-a",
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isReusableAgentViewerServer({
|
||||
...distServer,
|
||||
serverMode: "serve",
|
||||
}, {
|
||||
mode: "dev",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-a",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("agentViewerUrl includes the selected absolute directory", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const url = agentViewerUrl("http://127.0.0.1:4178", directory);
|
||||
assert.equal(new URL(url).searchParams.get("dir"), directory);
|
||||
});
|
||||
|
||||
test("activateAgentViewerDirectory posts the requested dir to the activation endpoint", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const requests = [];
|
||||
|
||||
const result = await activateAgentViewerDirectory({
|
||||
baseUrl: "http://127.0.0.1:4178",
|
||||
directory,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url: String(url), method: options?.method || "GET" });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
directory: {
|
||||
dir: directory,
|
||||
rootPath: directory,
|
||||
rootName: path.basename(directory),
|
||||
},
|
||||
server: {
|
||||
app: "cad-viewer",
|
||||
serverFeatures: ["directory-activation"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.directory, directory);
|
||||
assert.equal(new URL(result.viewerUrl).searchParams.get("dir"), directory);
|
||||
assert.deepEqual(result.activeDirectory, {
|
||||
dir: directory,
|
||||
rootPath: directory,
|
||||
rootName: path.basename(directory),
|
||||
});
|
||||
assert.equal(requests[0].method, "POST");
|
||||
assert.equal(new URL(requests[0].url).pathname, "/__cad/directory/activate");
|
||||
assert.equal(new URL(requests[0].url).searchParams.get("dir"), directory);
|
||||
});
|
||||
|
||||
test("resolveAgentViewerPort reuses matching registry servers before free lower ports", async () => {
|
||||
const probes = [];
|
||||
const result = await resolveAgentViewerPort({
|
||||
forwardedArgs: ["--host", "127.0.0.1", "--port", "4178"],
|
||||
git: "git-a",
|
||||
registryServers: [{
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
port: 5173,
|
||||
url: "http://127.0.0.1:5173",
|
||||
}],
|
||||
probePort: async ({ host, port }) => {
|
||||
probes.push(`${host}:${port}`);
|
||||
return {
|
||||
status: "viewer",
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
serverInfo: {
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-a",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.action, "reuse");
|
||||
assert.equal(result.port, 5173);
|
||||
assert.deepEqual(probes, ["127.0.0.1:5173"]);
|
||||
});
|
||||
|
||||
test("resolveAgentViewerPort reuses matching-version dist servers across git branches", async () => {
|
||||
const probes = [];
|
||||
const result = await resolveAgentViewerPort({
|
||||
forwardedArgs: ["--host", "127.0.0.1", "--port", "4178"],
|
||||
git: "git-a",
|
||||
mode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
registryServers: [{
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
serverMode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-b",
|
||||
port: 5173,
|
||||
url: "http://127.0.0.1:5173",
|
||||
}],
|
||||
probePort: async ({ host, port }) => {
|
||||
probes.push(`${host}:${port}`);
|
||||
return {
|
||||
status: "viewer",
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
serverInfo: {
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
serverMode: "serve",
|
||||
viewerVersion: "0.3.2",
|
||||
git: "git-b",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.action, "reuse");
|
||||
assert.equal(result.port, 5173);
|
||||
assert.deepEqual(probes, ["127.0.0.1:5173"]);
|
||||
});
|
||||
|
||||
test("resolveAgentViewerPort skips other viewers and starts on the first closed port", async () => {
|
||||
const probes = [];
|
||||
const result = await resolveAgentViewerPort({
|
||||
forwardedArgs: ["--port", "4178"],
|
||||
git: "git-a",
|
||||
registryServers: [],
|
||||
portScanLimit: 3,
|
||||
probePort: async ({ host, port }) => {
|
||||
probes.push(port);
|
||||
if (port === 4178) {
|
||||
return {
|
||||
status: "viewer",
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
serverInfo: {
|
||||
app: "cad-viewer",
|
||||
serverApiVersion: 2,
|
||||
dynamicRoot: true,
|
||||
serverFeatures: ["directory-activation"],
|
||||
git: "git-b",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "closed",
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.action, "start");
|
||||
assert.equal(result.port, 4179);
|
||||
assert.deepEqual(probes, [4178, 4179]);
|
||||
});
|
||||
|
||||
test("probeAgentViewerPort reports permission-blocked local probes", async () => {
|
||||
const error = new TypeError("fetch failed");
|
||||
error.cause = { code: "EPERM" };
|
||||
const result = await probeAgentViewerPort({
|
||||
port: 4178,
|
||||
fetchImpl: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.port, 4178);
|
||||
});
|
||||
|
||||
test("resolveAgentStartLaunch starts the selected free port", async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const result = await resolveAgentStartLaunch({
|
||||
argv: ["--viewer-start-mode", "serve", "--host", "127.0.0.1", "--dir", directory, "--port", "4178"],
|
||||
env: {},
|
||||
packageRoot: "/project/viewer",
|
||||
nodePath: "/node",
|
||||
registryServers: [],
|
||||
probePort: async ({ host, port }) => ({
|
||||
status: port === 4178 ? "occupied" : "closed",
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.action, "start");
|
||||
assert.equal(result.port, 4179);
|
||||
assert.deepEqual(result.command.args, [
|
||||
"/project/viewer/src/server/server.mjs",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--dir",
|
||||
directory,
|
||||
"--port",
|
||||
"4179",
|
||||
]);
|
||||
assert.equal(new URL(result.viewerUrl).searchParams.get("dir"), directory);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
carryForwardRemoteArtifactStatuses,
|
||||
createIgnoreMatcher,
|
||||
DEFAULT_UPLOAD_EXCLUDE_PATTERNS,
|
||||
parseIgnorePatterns,
|
||||
parseUploadArgs,
|
||||
rewriteCatalogForBlob,
|
||||
uploadCatalogDirectoryToVercelBlob,
|
||||
} from "./upload-catalog-to-blob.mjs";
|
||||
|
||||
function makeTempRepo() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "cad-viewer-blob-upload-"));
|
||||
}
|
||||
|
||||
function writeFile(filePath, content = "") {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function shortHash(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function fullHash(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function gitLfsPointer({ oid, size }) {
|
||||
return [
|
||||
"version https://git-lfs.github.com/spec/v1",
|
||||
`oid sha256:${oid}`,
|
||||
`size ${size}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("upload ignore patterns support comments, directory patterns, globs, and negation", () => {
|
||||
const patterns = parseIgnorePatterns(`
|
||||
# comment
|
||||
/mechbench/
|
||||
*.tmp
|
||||
!keep.tmp
|
||||
`);
|
||||
const ignored = createIgnoreMatcher(patterns);
|
||||
|
||||
assert.equal(ignored({ relativePath: "mechbench", isDirectory: true }), true);
|
||||
assert.equal(ignored({ relativePath: "mechbench/part.step" }), true);
|
||||
assert.equal(ignored({ relativePath: "nested/file.tmp" }), true);
|
||||
assert.equal(ignored({ relativePath: "keep.tmp" }), false);
|
||||
assert.equal(ignored({ relativePath: "parts/keep.step" }), false);
|
||||
});
|
||||
|
||||
test("parseUploadArgs accepts a directory and repeated ignore options", () => {
|
||||
assert.deepEqual(
|
||||
parseUploadArgs([
|
||||
"models",
|
||||
"--ignore-file",
|
||||
".vieweruploadignore",
|
||||
"--exclude",
|
||||
"/mechbench/",
|
||||
"--exclude",
|
||||
"/mechbench2/",
|
||||
"--concurrency",
|
||||
"2",
|
||||
"--skip-existing",
|
||||
"--fetch-missing-lfs",
|
||||
], {}),
|
||||
{
|
||||
directory: "models",
|
||||
ignoreFiles: [".vieweruploadignore"],
|
||||
excludePatterns: ["/mechbench/", "/mechbench2/"],
|
||||
concurrency: 2,
|
||||
skipExisting: true,
|
||||
fetchMissingLfs: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("parseUploadArgs rejects removed root-dir and workspace-root flags", () => {
|
||||
assert.throws(
|
||||
() => parseUploadArgs(["--root-dir", "/repo/models"], {}),
|
||||
/--root-dir has been removed/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseUploadArgs(["--root-dir=/repo/models"], {}),
|
||||
/--root-dir has been removed/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseUploadArgs(["--workspace-root", "/repo"], {}),
|
||||
/Unknown option: --workspace-root/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseUploadArgs([], { VIEWER_LOCAL_ROOT_DIR: "/repo/models" }),
|
||||
/VIEWER_LOCAL_ROOT_DIR.*removed/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseUploadArgs([], { VIEWER_LOCAL_WORKSPACE_ROOT: "/repo" }),
|
||||
/VIEWER_LOCAL_ROOT_DIR.*removed/
|
||||
);
|
||||
});
|
||||
|
||||
test("rewriteCatalogForBlob annotates STEP assets without publishing Python source refs", () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
const rootPath = path.join(repoRoot, "models");
|
||||
writeFile(path.join(rootPath, "parts/bracket.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
|
||||
writeFile(path.join(rootPath, "parts/bracket.py"), "def gen_step():\n return None\n");
|
||||
const uploads = new Map([
|
||||
["parts/bracket.step", {
|
||||
fileRef: "parts/bracket.step",
|
||||
filePath: path.join(rootPath, "parts/bracket.step"),
|
||||
url: "https://blob.test/models2/parts/bracket.step",
|
||||
hash: "step-hash",
|
||||
bytes: 12,
|
||||
}],
|
||||
["parts/bracket.py", {
|
||||
fileRef: "parts/bracket.py",
|
||||
filePath: path.join(rootPath, "parts/bracket.py"),
|
||||
url: "https://blob.test/models2/parts/bracket.py",
|
||||
hash: "py-hash",
|
||||
bytes: 34,
|
||||
}],
|
||||
]);
|
||||
|
||||
const catalog = rewriteCatalogForBlob({
|
||||
schemaVersion: 4,
|
||||
entries: [
|
||||
{
|
||||
file: "parts/bracket.step",
|
||||
kind: "part",
|
||||
sourceKind: "python",
|
||||
source: {
|
||||
file: "models/parts/bracket.py",
|
||||
sourcePath: "models/parts/bracket.py",
|
||||
sourceHash: "source-hash",
|
||||
},
|
||||
sourceStatus: {
|
||||
sourceKind: "python",
|
||||
sourcePath: "models/parts/bracket.py",
|
||||
},
|
||||
artifact: {
|
||||
ok: false,
|
||||
error: "missing_source_path",
|
||||
sourceKind: "python",
|
||||
stepPath: "parts/bracket.step",
|
||||
glbPath: "parts/.bracket.step.glb",
|
||||
message: "GLB STEP_topology is missing required sourcePath identity: parts/.bracket.step.glb.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}, {
|
||||
uploads,
|
||||
repoRoot,
|
||||
rootPath,
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog.entries[0].step, {
|
||||
file: "parts/bracket.step",
|
||||
url: "https://blob.test/models2/parts/bracket.step",
|
||||
hash: "step-hash",
|
||||
bytes: 12,
|
||||
});
|
||||
assert.equal(catalog.entries[0].sourceKind, "python");
|
||||
assert.equal(catalog.entries[0].source, undefined);
|
||||
assert.equal(catalog.entries[0].sourceStatus, undefined);
|
||||
assert.equal(catalog.entries[0].artifact, undefined);
|
||||
assert.equal(JSON.stringify(catalog).includes(".py"), false);
|
||||
});
|
||||
|
||||
test("rewriteCatalogForBlob keeps non-source-path STEP artifact warnings", () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
const rootPath = path.join(repoRoot, "models");
|
||||
const catalog = rewriteCatalogForBlob({
|
||||
schemaVersion: 4,
|
||||
entries: [
|
||||
{
|
||||
file: "parts/bracket.step",
|
||||
kind: "part",
|
||||
sourceKind: "python",
|
||||
artifact: {
|
||||
ok: false,
|
||||
error: "missing_edge_topology",
|
||||
sourceKind: "python",
|
||||
stepPath: "parts/bracket.step",
|
||||
glbPath: "parts/.bracket.step.glb",
|
||||
message: "STEP topology validation requires readable STEP_topology edgeView in the GLB.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}, {
|
||||
uploads: new Map(),
|
||||
repoRoot,
|
||||
rootPath,
|
||||
});
|
||||
|
||||
assert.equal(catalog.entries[0].artifact.error, "missing_edge_topology");
|
||||
});
|
||||
|
||||
test("uploadCatalogDirectoryToVercelBlob applies default catalog exclusions", async () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
writeFile(path.join(repoRoot, "models/keep.stl"), "solid keep\nendsolid keep\n");
|
||||
writeFile(path.join(repoRoot, "models/part.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
|
||||
const stepModuleBody = "export default { manifest: { schemaVersion: 1 } };\n";
|
||||
writeFile(path.join(repoRoot, "models/.part.step.js"), stepModuleBody);
|
||||
writeFile(path.join(repoRoot, "models/mechbench/skipped.stl"), "solid skip\nendsolid skip\n");
|
||||
writeFile(path.join(repoRoot, "models/mechbench2/skipped.stl"), "solid skip\nendsolid skip\n");
|
||||
writeFile(path.join(repoRoot, "models/7dof_arm/skipped.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
|
||||
writeFile(path.join(repoRoot, "models/source.py"), "def gen_step():\n return None\n");
|
||||
const putCalls = [];
|
||||
|
||||
const result = await uploadCatalogDirectoryToVercelBlob({
|
||||
directory: "models",
|
||||
env: {
|
||||
VIEWER_ASSET_BACKEND: "vercel-blob",
|
||||
VIEWER_VERCEL_BLOB_PREFIX: "models2",
|
||||
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
|
||||
},
|
||||
cwd: repoRoot,
|
||||
client: {
|
||||
put: async (pathname, body, options) => {
|
||||
putCalls.push({ pathname, body, options });
|
||||
return { pathname, url: `https://blob.test/${pathname}` };
|
||||
},
|
||||
},
|
||||
logger: { log() {} },
|
||||
});
|
||||
|
||||
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
|
||||
`models2/.part.step.${shortHash(stepModuleBody)}.js`,
|
||||
"models2/catalog.json",
|
||||
"models2/keep.stl",
|
||||
"models2/part.step",
|
||||
]);
|
||||
assert.equal(
|
||||
putCalls.find((call) => call.pathname === `models2/.part.step.${shortHash(stepModuleBody)}.js`).options.contentType,
|
||||
"text/javascript; charset=utf-8",
|
||||
);
|
||||
assert.equal(putCalls.find((call) => call.pathname === "models2/keep.stl").options.contentType, "model/stl");
|
||||
assert.equal(result.uploadedFiles, 3);
|
||||
assert.equal(result.catalogEntries, 2);
|
||||
assert.equal(result.rootDir, "");
|
||||
assert.equal(result.rootPath, path.join(repoRoot, "models"));
|
||||
|
||||
const catalogUpload = putCalls.find((call) => call.pathname === "models2/catalog.json");
|
||||
assert.equal(catalogUpload.options.cacheControlMaxAge, 60);
|
||||
assert.equal(putCalls.find((call) => call.pathname === "models2/keep.stl").options.cacheControlMaxAge, undefined);
|
||||
const uploadedCatalog = JSON.parse(catalogUpload.body);
|
||||
assert.deepEqual(uploadedCatalog.entries.map((entry) => entry.file), ["keep.stl", "part.step"]);
|
||||
assert.equal(uploadedCatalog.entries[0].url, "https://blob.test/models2/keep.stl");
|
||||
assert.equal(uploadedCatalog.entries[1].step.url, "https://blob.test/models2/part.step");
|
||||
assert.equal(
|
||||
uploadedCatalog.entries[1].moduleUrl,
|
||||
`https://blob.test/models2/.part.step.${shortHash(stepModuleBody)}.js`
|
||||
);
|
||||
assert.deepEqual(result.ignoredPatterns.slice(0, DEFAULT_UPLOAD_EXCLUDE_PATTERNS.length), DEFAULT_UPLOAD_EXCLUDE_PATTERNS);
|
||||
});
|
||||
|
||||
test("uploadCatalogDirectoryToVercelBlob skips assets already present in the remote catalog", async () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
const existingContent = "solid existing\nendsolid existing\n";
|
||||
const existingHash = fullHash(existingContent);
|
||||
const existingBytes = Buffer.byteLength(existingContent);
|
||||
writeFile(path.join(repoRoot, "models/existing.stl"), gitLfsPointer({
|
||||
oid: existingHash,
|
||||
size: existingBytes,
|
||||
}));
|
||||
writeFile(path.join(repoRoot, "models/new.stl"), "solid new\nendsolid new\n");
|
||||
const remoteCatalog = {
|
||||
schemaVersion: 4,
|
||||
entries: [{
|
||||
file: "existing.stl",
|
||||
kind: "mesh",
|
||||
url: "https://blob.test/models2/existing.stl",
|
||||
hash: existingHash,
|
||||
bytes: existingBytes,
|
||||
}],
|
||||
};
|
||||
const putCalls = [];
|
||||
const getCalls = [];
|
||||
|
||||
const result = await uploadCatalogDirectoryToVercelBlob({
|
||||
directory: "models",
|
||||
skipExisting: true,
|
||||
env: {
|
||||
VIEWER_ASSET_BACKEND: "vercel-blob",
|
||||
VIEWER_VERCEL_BLOB_PREFIX: "models2",
|
||||
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
|
||||
},
|
||||
cwd: repoRoot,
|
||||
client: {
|
||||
get: async (pathname, options) => {
|
||||
getCalls.push({ pathname, options });
|
||||
return {
|
||||
stream: new Response(JSON.stringify(remoteCatalog)).body,
|
||||
};
|
||||
},
|
||||
put: async (pathname, body, options) => {
|
||||
putCalls.push({ pathname, body, options });
|
||||
return { pathname, url: `https://blob.test/${pathname}` };
|
||||
},
|
||||
},
|
||||
logger: { log() {} },
|
||||
});
|
||||
|
||||
assert.deepEqual(getCalls, [{
|
||||
pathname: "models2/catalog.json",
|
||||
options: {
|
||||
access: "public",
|
||||
token: "test-token",
|
||||
},
|
||||
}]);
|
||||
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
|
||||
"models2/catalog.json",
|
||||
"models2/new.stl",
|
||||
]);
|
||||
assert.equal(result.uploadedFiles, 1);
|
||||
assert.equal(result.skippedFiles, 1);
|
||||
|
||||
const uploadedCatalog = JSON.parse(putCalls.find((call) => call.pathname === "models2/catalog.json").body);
|
||||
assert.equal(putCalls.find((call) => call.pathname === "models2/catalog.json").options.cacheControlMaxAge, 60);
|
||||
const existingEntry = uploadedCatalog.entries.find((entry) => entry.file === "existing.stl");
|
||||
assert.equal(existingEntry.url, "https://blob.test/models2/existing.stl");
|
||||
assert.equal(existingEntry.hash, existingHash);
|
||||
assert.equal(existingEntry.bytes, existingBytes);
|
||||
});
|
||||
|
||||
test("uploadCatalogDirectoryToVercelBlob rejects missing Git LFS pointers without explicit fetching", async () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
writeFile(path.join(repoRoot, "models/missing.stl"), gitLfsPointer({
|
||||
oid: "a".repeat(64),
|
||||
size: 12345,
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadCatalogDirectoryToVercelBlob({
|
||||
directory: "models",
|
||||
env: {
|
||||
VIEWER_ASSET_BACKEND: "vercel-blob",
|
||||
VIEWER_VERCEL_BLOB_PREFIX: "models2",
|
||||
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
|
||||
},
|
||||
cwd: repoRoot,
|
||||
client: {
|
||||
put: async () => {
|
||||
throw new Error("LFS pointer should not be uploaded");
|
||||
},
|
||||
},
|
||||
logger: { log() {} },
|
||||
}),
|
||||
/missing\.stl is a Git LFS pointer/
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadCatalogDirectoryToVercelBlob honors positional directory from npm caller cwd", async () => {
|
||||
const repoRoot = makeTempRepo();
|
||||
writeFile(path.join(repoRoot, "models/keep.stl"), "solid keep\nendsolid keep\n");
|
||||
writeFile(path.join(repoRoot, "viewer/package.json"), "{}\n");
|
||||
const putCalls = [];
|
||||
|
||||
const result = await uploadCatalogDirectoryToVercelBlob({
|
||||
directory: "models",
|
||||
env: {
|
||||
INIT_CWD: repoRoot,
|
||||
VIEWER_ASSET_BACKEND: "vercel-blob",
|
||||
VIEWER_VERCEL_BLOB_PREFIX: "models2",
|
||||
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
|
||||
},
|
||||
cwd: path.join(repoRoot, "viewer"),
|
||||
client: {
|
||||
put: async (pathname, body, options) => {
|
||||
putCalls.push({ pathname, body, options });
|
||||
return { pathname, url: `https://blob.test/${pathname}` };
|
||||
},
|
||||
},
|
||||
logger: { log() {} },
|
||||
});
|
||||
|
||||
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
|
||||
"models2/catalog.json",
|
||||
"models2/keep.stl",
|
||||
]);
|
||||
assert.equal(result.catalogEntries, 1);
|
||||
assert.equal(result.rootDir, "");
|
||||
assert.equal(result.rootPath, path.join(repoRoot, "models"));
|
||||
});
|
||||
|
||||
test("carryForwardRemoteArtifactStatuses reuses remote status for unchanged LFS pointer artifacts", () => {
|
||||
const rootPath = makeTempRepo();
|
||||
const glbHash = fullHash("real glb bytes");
|
||||
writeFile(path.join(rootPath, ".part.step.glb"), gitLfsPointer({ oid: glbHash, size: 1234 }));
|
||||
writeFile(path.join(rootPath, ".real.step.glb"), "actual binary content");
|
||||
|
||||
const pointerWarning = {
|
||||
ok: false,
|
||||
error: "missing_step_topology",
|
||||
glbPath: ".part.step.glb",
|
||||
message: "STEP topology validation requires readable STEP_topology indexView in the GLB: .part.step.glb.",
|
||||
};
|
||||
const realFileWarning = {
|
||||
ok: false,
|
||||
error: "missing_step_topology",
|
||||
glbPath: ".real.step.glb",
|
||||
message: "STEP topology validation requires readable STEP_topology indexView in the GLB: .real.step.glb.",
|
||||
};
|
||||
const catalog = {
|
||||
schemaVersion: 4,
|
||||
entries: [
|
||||
{ file: "part.step", hash: glbHash, artifact: pointerWarning },
|
||||
{ file: "real.step", hash: fullHash("actual binary content"), artifact: realFileWarning },
|
||||
{ file: "changed.step", hash: "different-local-hash", artifact: { ...pointerWarning } },
|
||||
],
|
||||
};
|
||||
const existingCatalog = {
|
||||
schemaVersion: 4,
|
||||
entries: [
|
||||
{ file: "part.step", hash: glbHash },
|
||||
{ file: "real.step", hash: fullHash("actual binary content") },
|
||||
{ file: "changed.step", hash: "remote-hash" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = carryForwardRemoteArtifactStatuses(catalog, { existingCatalog, rootPath });
|
||||
|
||||
assert.equal(result.entries.find((entry) => entry.file === "part.step").artifact, undefined);
|
||||
assert.deepEqual(
|
||||
result.entries.find((entry) => entry.file === "real.step").artifact,
|
||||
realFileWarning,
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.entries.find((entry) => entry.file === "changed.step").artifact,
|
||||
pointerWarning,
|
||||
);
|
||||
});
|
||||
|
||||
test("carryForwardRemoteArtifactStatuses copies remote warnings instead of pointer-derived ones", () => {
|
||||
const rootPath = makeTempRepo();
|
||||
const glbHash = fullHash("glb with legit warning");
|
||||
writeFile(path.join(rootPath, ".warned.step.glb"), gitLfsPointer({ oid: glbHash, size: 99 }));
|
||||
|
||||
const remoteWarning = {
|
||||
ok: false,
|
||||
error: "unsupported_step_topology",
|
||||
glbPath: ".warned.step.glb",
|
||||
message: "STEP topology validation requires STEP_topology schemaVersion 2 in the GLB.",
|
||||
};
|
||||
const result = carryForwardRemoteArtifactStatuses(
|
||||
{
|
||||
schemaVersion: 4,
|
||||
entries: [{
|
||||
file: "warned.step",
|
||||
hash: glbHash,
|
||||
artifact: {
|
||||
ok: false,
|
||||
error: "missing_step_topology",
|
||||
glbPath: ".warned.step.glb",
|
||||
message: "unreadable pointer",
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
existingCatalog: {
|
||||
schemaVersion: 4,
|
||||
entries: [{ file: "warned.step", hash: glbHash, artifact: remoteWarning }],
|
||||
},
|
||||
rootPath,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result.entries[0].artifact, remoteWarning);
|
||||
});
|
||||
Reference in New Issue
Block a user