diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index e469d393..18e0cae6 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -20,6 +20,7 @@ const { restoreMissingOpenclawConfigFromRemote, } = require("../lib/cli/openclaw-config-restore"); const { buildSecretReplacements } = require("../lib/server/helpers"); +const { resolveSelfDependency } = require("../lib/server/self-dependency"); const { migrateLegacyTelegramStreamingConfig, } = require("../lib/server/openclaw-config-migrations"); @@ -189,29 +190,34 @@ console.log(`[alphaclaw] Root directory: ${rootDir}`); // from the fresh container using the persistent volume marker. const pendingUpdateMarker = path.join(rootDir, ".alphaclaw-update-pending"); if (fs.existsSync(pendingUpdateMarker)) { - console.log( - "[alphaclaw] Pending update detected, installing @chrysb/alphaclaw@latest...", - ); - const alphaPkgRoot = path.resolve(__dirname, ".."); - const nmIndex = alphaPkgRoot.lastIndexOf( - `${path.sep}node_modules${path.sep}`, - ); - const installDir = - nmIndex >= 0 ? alphaPkgRoot.slice(0, nmIndex) : alphaPkgRoot; - try { - execSync( - "npm install @chrysb/alphaclaw@latest --omit=dev --prefer-online", - { - cwd: installDir, - stdio: "inherit", - timeout: 180000, - }, + const selfDep = resolveSelfDependency({ fsImpl: fs }); + if (selfDep.isGit) { + // Git-based installs update by redeploying (which reinstalls from the pinned + // ref), not by `npm install @latest`. Clear the marker and move on. + console.log( + "[alphaclaw] Pending update marker found, but this install is git-based; updates apply on redeploy. Skipping npm install.", ); fs.unlinkSync(pendingUpdateMarker); - console.log("[alphaclaw] Update applied successfully"); - } catch (e) { - console.log(`[alphaclaw] Update install failed: ${e.message}`); - fs.unlinkSync(pendingUpdateMarker); + } else { + const selfUpdatePackageName = selfDep.key || "alphaclaw"; + console.log( + `[alphaclaw] Pending update detected, installing ${selfUpdatePackageName}@latest...`, + ); + try { + execSync( + `npm install ${selfUpdatePackageName}@latest --omit=dev --prefer-online`, + { + cwd: selfDep.installDir, + stdio: "inherit", + timeout: 180000, + }, + ); + fs.unlinkSync(pendingUpdateMarker); + console.log("[alphaclaw] Update applied successfully"); + } catch (e) { + console.log(`[alphaclaw] Update install failed: ${e.message}`); + fs.unlinkSync(pendingUpdateMarker); + } } } @@ -894,6 +900,22 @@ if (fs.existsSync(configPath)) { console.log("[alphaclaw] Discord added"); changed = true; } + // Drop usage-tracker plugin paths left by a previous install location (e.g. a + // prior @chrysb/alphaclaw npm install at /app/node_modules/@chrysb/alphaclaw/... + // after switching to a git dependency at /app/node_modules/alphaclaw/...). The + // dead path makes OpenClaw reject the whole config. This block runs on every + // boot whenever a config exists — onboarded or not — so it is the migration's + // load-bearing prune; the onboarded reconcile prune is a backstop. + const usageTrackerPathPattern = /[\\/]plugin[\\/]usage-tracker[\\/]?$/; + const prunedPaths = cfg.plugins.load.paths.filter( + (entry) => + entry === kUsageTrackerPluginPath || + !usageTrackerPathPattern.test(String(entry || "")), + ); + if (prunedPaths.length !== cfg.plugins.load.paths.length) { + cfg.plugins.load.paths = prunedPaths; + changed = true; + } if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { cfg.plugins.load.paths.push(kUsageTrackerPluginPath); changed = true; diff --git a/lib/server.js b/lib/server.js index e0ae22e4..de78afdd 100644 --- a/lib/server.js +++ b/lib/server.js @@ -291,6 +291,7 @@ const watchdog = createWatchdog({ reloadEnv, resolveSetupUrl, resolveGatewayHealthUrl: () => `${getGatewayUrl()}/health`, + resolveGatewayReadyzUrl: () => `${getGatewayUrl()}/readyz`, }); const watchdogTerminal = createWatchdogTerminalService({ cwd: constants.OPENCLAW_DIR, diff --git a/lib/server/alphaclaw-version.js b/lib/server/alphaclaw-version.js index f3337785..0c52ae5a 100644 --- a/lib/server/alphaclaw-version.js +++ b/lib/server/alphaclaw-version.js @@ -14,6 +14,10 @@ const { normalizeOpenclawVersion, resolveGithubRepoUrl, } = require("./helpers"); +const { + resolveSelfDependency, + looksLikeGitDependency, +} = require("./self-dependency"); const kGithubApiBaseUrl = "https://api.github.com/repos"; const kGithubRawBaseUrl = "https://raw.githubusercontent.com"; @@ -21,7 +25,7 @@ const kDefaultTemplateBranch = "main"; const kRailwayTemplateRepoUrl = "https://github.com/chrysb/openclaw-railway-template.git"; const kRenderTemplateRepoUrl = - "https://github.com/chrysb/openclaw-render-template.git"; + "https://github.com/garrytan/openclaw-render-template.git"; const kApexTemplateRepoUrl = "https://github.com/chrysb/openclaw-apex-template.git"; @@ -91,10 +95,18 @@ const buildGithubHeaders = ({ env = process.env, accept = "application/json" } = return headers; }; -const extractTemplateVersions = (pkg) => ({ - latestVersion: normalizeVersion(pkg?.dependencies?.["@chrysb/alphaclaw"]), - latestOpenclawVersion: normalizeOpenclawVersion(pkg?.dependencies?.openclaw), -}); +const extractTemplateVersions = (pkg) => { + const alphaclawSpec = + pkg?.dependencies?.["alphaclaw"] || pkg?.dependencies?.["@chrysb/alphaclaw"]; + return { + // A git-pinned template has no semver to compare against — report null so we + // never surface a bogus "update available" from a git URL string. + latestVersion: looksLikeGitDependency(alphaclawSpec) + ? null + : normalizeVersion(alphaclawSpec), + latestOpenclawVersion: normalizeOpenclawVersion(pkg?.dependencies?.openclaw), + }; +}; const fetchLatestVersionFromRegistry = async ({ fetchImpl, version = null }) => { if (typeof fetchImpl !== "function") { @@ -343,12 +355,33 @@ const detectUpdateStrategy = ({ }); } + // Git-based installs (e.g. `"alphaclaw": "git+https://github.com//alphaclaw.git#main"`) + // can't be updated by `npm install @latest` from the registry — updates come + // from pulling the source and reinstalling/redeploying. Keep the in-place npm + // self-update only when AlphaClaw is pinned to an npm version. + const selfDep = resolveSelfDependency({ fsImpl }); + if (selfDep.isGit) { + return createUpdateStrategy({ + action: "instructions", + provider: "git", + label: "Git source", + description: + "This AlphaClaw is installed from a git repository. Update by pulling the latest commit of your source repo and reinstalling (or redeploying), then restart AlphaClaw.", + steps: [ + "Pull the latest commit of your AlphaClaw source repository", + "Reinstall dependencies so the new version is built (npm install)", + "Restart AlphaClaw to load the update", + ], + primaryActionLabel: "Done", + }); + } + + const selfUpdatePackageName = selfDep.key || "alphaclaw"; return createUpdateStrategy({ action: "self-update", provider: "self-hosted", label: "This install", - description: - "This will install the latest @chrysb/alphaclaw package in place and restart AlphaClaw.", + description: `This will install the latest ${selfUpdatePackageName} package in place and restart AlphaClaw.`, steps: [ "AlphaClaw will install the latest published package in place", "The process will restart after the new files are copied into node_modules", @@ -451,7 +484,9 @@ const createAlphaclawVersionService = ({ const installLatestAlphaclaw = () => new Promise((resolve, reject) => { - const installDir = findInstallDir(fsImpl); + const selfDep = resolveSelfDependency({ fsImpl }); + const installDir = selfDep.installDir; + const selfUpdatePackageName = selfDep.key || "alphaclaw"; const tmpDir = fsImpl.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-update-")); const cleanup = () => { @@ -464,7 +499,7 @@ const createAlphaclawVersionService = ({ path.join(tmpDir, "package.json"), JSON.stringify({ private: true, - dependencies: { "@chrysb/alphaclaw": "latest" }, + dependencies: { [selfUpdatePackageName]: "latest" }, }), ); @@ -476,7 +511,7 @@ const createAlphaclawVersionService = ({ }; console.log( - `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest in temp dir (target: ${installDir})`, + `[alphaclaw] Running: npm install ${selfUpdatePackageName}@latest in temp dir (target: ${installDir})`, ); childProcess.exec( "npm install --omit=dev --prefer-online --package-lock=false", @@ -494,7 +529,7 @@ const createAlphaclawVersionService = ({ cleanup(); return reject( new Error( - message || "Failed to install @chrysb/alphaclaw@latest", + message || `Failed to install ${selfUpdatePackageName}@latest`, ), ); } @@ -616,6 +651,12 @@ const createAlphaclawVersionService = ({ const getVersionStatus = async (refresh) => { const strategy = detectUpdateStrategy({ env, fsImpl }); try { + if (strategy.provider === "git") { + // Git-sourced installs have no npm registry or template version to compare + // against — updates come from redeploying the pinned ref. Report current + // state without a remote version check. + return buildVersionStatus({ strategy }); + } if (strategy.templateRepoUrl) { const status = await readTemplateStatus({ repoUrl: strategy.templateRepoUrl, @@ -719,35 +760,6 @@ const createAlphaclawVersionService = ({ }; }; -const findInstallDir = (fsImpl) => { - let dir = kNpmPackageRoot; - while (dir !== path.dirname(dir)) { - const parent = path.dirname(dir); - if ( - path.basename(parent) === "node_modules" || - parent.includes(`${path.sep}node_modules${path.sep}`) - ) { - dir = parent; - continue; - } - const pkgPath = path.join(parent, "package.json"); - if (fsImpl.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fsImpl.readFileSync(pkgPath, "utf8")); - if ( - pkg.dependencies?.["@chrysb/alphaclaw"] || - pkg.devDependencies?.["@chrysb/alphaclaw"] || - pkg.optionalDependencies?.["@chrysb/alphaclaw"] - ) { - return parent; - } - } catch {} - } - dir = parent; - } - return kNpmPackageRoot; -}; - module.exports = { createAlphaclawVersionService, detectUpdateStrategy, diff --git a/lib/server/constants.js b/lib/server/constants.js index d5f4c044..cd2a6ab7 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -229,7 +229,7 @@ const kOpenclawUpdateCopyTimeoutMs = 5 * 60 * 1000; const kOpenclawRegistryUrl = "https://registry.npmjs.org/openclaw"; const kAlphaclawRegistryUrl = "https://registry.npmjs.org/@chrysb%2falphaclaw"; const kAlphaclawGithubReleasesBaseUrl = - "https://api.github.com/repos/chrysb/alphaclaw/releases"; + "https://api.github.com/repos/garrytan/alphaclaw/releases"; const kAppDir = kNpmPackageRoot; const kMaxPayloadBytes = parsePositiveInt(process.env.WEBHOOK_LOG_MAX_BYTES, 50 * 1024); const kWebhookPruneDays = parsePositiveInt(process.env.WEBHOOK_LOG_RETENTION_DAYS, 30); diff --git a/lib/server/openclaw-version.js b/lib/server/openclaw-version.js index b728388b..7d56a870 100644 --- a/lib/server/openclaw-version.js +++ b/lib/server/openclaw-version.js @@ -10,6 +10,7 @@ const { } = require("./constants"); const { normalizeOpenclawVersion } = require("./helpers"); const { parseJsonObjectFromNoisyOutput } = require("./utils/json"); +const { resolveSelfDependency } = require("./self-dependency"); const { assertSupportedNodeVersion } = require("../node-runtime"); const createOpenclawVersionService = ({ @@ -89,35 +90,10 @@ const createOpenclawVersionService = ({ } }; - const findInstallDir = () => { - // Resolve the consumer app root (for example /app in Docker), not this package directory. - let dir = kNpmPackageRoot; - while (dir !== path.dirname(dir)) { - const parent = path.dirname(dir); - if ( - path.basename(parent) === "node_modules" || - parent.includes(`${path.sep}node_modules${path.sep}`) - ) { - dir = parent; - continue; - } - const pkgPath = path.join(parent, "package.json"); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - if ( - pkg.dependencies?.["@chrysb/alphaclaw"] || - pkg.devDependencies?.["@chrysb/alphaclaw"] || - pkg.optionalDependencies?.["@chrysb/alphaclaw"] - ) { - return parent; - } - } catch {} - } - dir = parent; - } - return kNpmPackageRoot; - }; + // Resolve the consumer app root (for example /app in Docker), not this package + // directory. Matches AlphaClaw under either the `alphaclaw` alias (git installs) + // or the `@chrysb/alphaclaw` npm scope. + const findInstallDir = () => resolveSelfDependency({ fsImpl: fs }).installDir; // Install to a temp directory, then copy into the real node_modules. // Running `npm install` directly in the app dir causes EBUSY on Docker diff --git a/lib/server/routes/watchdog.js b/lib/server/routes/watchdog.js index 45b1f078..23ddfef1 100644 --- a/lib/server/routes/watchdog.js +++ b/lib/server/routes/watchdog.js @@ -51,6 +51,19 @@ const registerWatchdogRoutes = ({ } }); + app.post("/api/watchdog/resume-channels", requireAuth, async (req, res) => { + try { + const result = await watchdog.resumeChannels(); + if (result?.skipped) { + res.status(409).json({ ok: false, error: result.reason, result }); + return; + } + res.json({ ok: !!result?.ok, result }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + app.get("/api/watchdog/settings", requireAuth, (req, res) => { try { res.json({ ok: true, settings: watchdog.getSettings() }); diff --git a/lib/server/self-dependency.js b/lib/server/self-dependency.js new file mode 100644 index 00000000..83e32b22 --- /dev/null +++ b/lib/server/self-dependency.js @@ -0,0 +1,75 @@ +const fs = require("fs"); +const path = require("path"); +const { kNpmPackageRoot } = require("./constants"); + +// AlphaClaw may be consumed under different dependency keys: the published npm +// scope (`@chrysb/alphaclaw`) or a plain alias used by git-based deployments +// (`alphaclaw`, e.g. `"alphaclaw": "git+https://github.com//alphaclaw.git#main"`). +// Check the alias first so git deployments resolve correctly. +const kSelfDependencyKeys = ["alphaclaw", "@chrysb/alphaclaw"]; + +// A dependency spec points at a git source (rather than an npm version/range) +// when it uses a git protocol/shorthand or otherwise references a git host. +const looksLikeGitDependency = (spec) => { + const value = String(spec || "").trim(); + if (!value) return false; + return ( + /^(git\+|github:|gitlab:|bitbucket:|gist:|git:|git@|ssh:)/i.test(value) || + /\.git($|#)/i.test(value) || + /github\.com/i.test(value) + ); +}; + +const readDependencySpec = (pkg, key) => + pkg?.dependencies?.[key] || + pkg?.devDependencies?.[key] || + pkg?.optionalDependencies?.[key] || + null; + +// Find the consumer app root (e.g. /app in Docker) — the nearest ancestor +// package.json that declares AlphaClaw as a dependency — and report how it is +// pinned. Falls back to the AlphaClaw package root with no dependency info. +const resolveSelfDependency = ({ fsImpl = fs, startDir = kNpmPackageRoot } = {}) => { + let dir = startDir; + while (dir !== path.dirname(dir)) { + const parent = path.dirname(dir); + if ( + path.basename(parent) === "node_modules" || + parent.includes(`${path.sep}node_modules${path.sep}`) + ) { + dir = parent; + continue; + } + const pkgPath = path.join(parent, "package.json"); + if (fsImpl.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fsImpl.readFileSync(pkgPath, "utf8")); + for (const key of kSelfDependencyKeys) { + const spec = readDependencySpec(pkg, key); + if (spec) { + return { + installDir: parent, + key, + spec, + isGit: looksLikeGitDependency(spec), + }; + } + } + } catch {} + } + dir = parent; + } + return { installDir: kNpmPackageRoot, key: null, spec: null, isGit: false }; +}; + +// Resolve just the install dir (consumer app root) — preserves the prior +// findInstallDir() contract used by the OpenClaw/AlphaClaw self-updaters. +const findInstallDir = (fsImpl = fs) => + resolveSelfDependency({ fsImpl }).installDir; + +module.exports = { + kSelfDependencyKeys, + looksLikeGitDependency, + resolveSelfDependency, + findInstallDir, +}; diff --git a/lib/server/usage-tracker-config.js b/lib/server/usage-tracker-config.js index 24642351..6818ccee 100644 --- a/lib/server/usage-tracker-config.js +++ b/lib/server/usage-tracker-config.js @@ -12,6 +12,10 @@ const kUsageTrackerPluginPath = path.resolve( "plugin", "usage-tracker", ); +// Matches any `.../lib/plugin/usage-tracker` load path regardless of where the +// AlphaClaw package was installed (npm scope dir, git alias dir, version bumps). +const kUsageTrackerPluginPathPattern = /[\\/]plugin[\\/]usage-tracker[\\/]?$/; + const kConversationAccessHookPolicyKey = "allowConversationAccess"; const kChannelPluginIds = ["telegram", "discord", "slack", "whatsapp"]; const kDefaultDiscordGroupPolicy = "disabled"; @@ -56,6 +60,24 @@ const ensureUsageTrackerPluginEntry = (cfg = {}) => { return JSON.stringify(cfg) !== before; }; +// Remove usage-tracker plugin paths left behind by a previous install location +// (e.g. a prior `@chrysb/alphaclaw` npm install at `/app/node_modules/@chrysb/alphaclaw/...` +// after switching to a git dependency installed at `/app/node_modules/alphaclaw/...`). +// Only the current `__dirname`-resolved path is valid; any other entry pointing at a +// `.../plugin/usage-tracker` dir is dead and makes OpenClaw reject the whole config. +const pruneStaleUsageTrackerPaths = (cfg = {}) => { + ensurePluginsShell(cfg); + const paths = cfg.plugins.load.paths; + const filtered = paths.filter( + (entry) => + entry === kUsageTrackerPluginPath || + !kUsageTrackerPluginPathPattern.test(String(entry || "")), + ); + if (filtered.length === paths.length) return false; + cfg.plugins.load.paths = filtered; + return true; +}; + const hasDiscordGuildAllowlist = (discordConfig = {}) => { const guilds = discordConfig.guilds; return !!guilds && typeof guilds === "object" && Object.keys(guilds).length > 0; @@ -108,8 +130,11 @@ const ensureUsageTrackerPluginConfig = ({ fsModule, openclawDir }) => { openclawDir, fallback: {}, }); + // Migrate configs written by a previous install location before reconciling, + // so the canonical path is the only usage-tracker entry that remains. + const prunedStale = pruneStaleUsageTrackerPaths(cfg); const migrated = migrateLegacyTelegramStreamingConfig(cfg); - const changed = reconcileManagedPluginConfig(cfg) || migrated; + const changed = reconcileManagedPluginConfig(cfg) || prunedStale || migrated; if (!changed) return false; writeOpenclawConfig({ fsModule, @@ -125,6 +150,7 @@ module.exports = { kDefaultDiscordGroupPolicy, ensurePluginsShell, ensurePluginAllowed, + pruneStaleUsageTrackerPaths, ensureUsageTrackerPluginEntry, reconcileDiscordGroupPolicy, reconcileEnabledChannelPlugins, diff --git a/lib/server/watchdog.js b/lib/server/watchdog.js index 856e7fca..e9788ca0 100644 --- a/lib/server/watchdog.js +++ b/lib/server/watchdog.js @@ -11,6 +11,16 @@ const kHealthStartupGraceMs = 30 * 1000; const kBootstrapHealthCheckMs = 5 * 1000; const kExpectedRestartWindowMs = 15 * 1000; const kGatewayHealthTimeoutMs = 5 * 1000; +// OpenClaw 2026.7.1+ exits with EX_CONFIG (78, sysexits.h) on fatal +// configuration errors. The contract is "do not restart until the config is +// fixed" — restarting blindly recreates the restart storm the gateway is +// trying to prevent. +const kOpenclawConfigErrorExitCode = 78; + +const shellEscapeArg = (value) => { + const safeValue = String(value || ""); + return `'${safeValue.replace(/'/g, `'\\''`)}'`; +}; const isTruthy = (value) => ["1", "true", "yes", "on"].includes( @@ -42,6 +52,7 @@ const createWatchdog = ({ reloadEnv, resolveSetupUrl, resolveGatewayHealthUrl = () => "", + resolveGatewayReadyzUrl = () => "", }) => { const state = { lifecycle: "stopped", @@ -64,6 +75,9 @@ const createWatchdog = ({ awaitingAutoRepairRecovery: false, startupConsecutiveHealthFailures: 0, configurationErrorActive: false, + safeMode: false, + suppressedChannels: [], + safeModeNotifiedKey: "", }; let healthTimer = null; let bootstrapHealthTimer = null; @@ -338,6 +352,138 @@ const createWatchdog = ({ } }; + // OpenClaw 2026.7.1+ can boot into control-plane-safe mode after its own + // crash-loop breaker trips: /health stays green while channel autostart is + // suppressed. /readyz reports the suppressed channels. + const probeGatewayReadiness = async () => { + const readyzUrl = String(resolveGatewayReadyzUrl() || "").trim(); + if (!readyzUrl) { + return { ok: false, reason: "gateway readyz URL unavailable" }; + } + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + kGatewayHealthTimeoutMs, + ); + try { + const response = await fetch(readyzUrl, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + const rawBody = await response.text(); + let parsedBody = null; + try { + parsedBody = rawBody ? JSON.parse(rawBody) : null; + } catch {} + if (!response.ok || !parsedBody || typeof parsedBody !== "object") { + return { + ok: false, + reason: `gateway readyz returned HTTP ${response.status}`, + }; + } + return { + ok: true, + ready: parsedBody.ready !== false, + failing: Array.isArray(parsedBody.failing) ? parsedBody.failing : [], + suppressed: Array.isArray(parsedBody.suppressed) + ? parsedBody.suppressed.map((entry) => String(entry || "")).filter(Boolean) + : [], + }; + } catch (error) { + const message = + error?.name === "AbortError" + ? `gateway readyz timed out after ${kGatewayHealthTimeoutMs}ms` + : error?.message || "gateway readyz request failed"; + return { ok: false, reason: message }; + } finally { + clearTimeout(timeoutId); + } + }; + + const clearSafeModeState = () => { + state.safeMode = false; + state.suppressedChannels = []; + state.safeModeNotifiedKey = ""; + }; + + const evaluateChannelSuppression = async (source, correlationId) => { + const readiness = await probeGatewayReadiness(); + if (!readiness.ok) return; + const suppressed = readiness.suppressed; + if (suppressed.length > 0) { + const notifiedKey = suppressed.slice().sort().join(","); + const changed = state.safeModeNotifiedKey !== notifiedKey; + state.safeMode = true; + state.suppressedChannels = suppressed; + if (!changed) return; + state.safeModeNotifiedKey = notifiedKey; + logEvent( + "safe_mode", + source, + "failed", + { suppressed, failing: readiness.failing }, + correlationId, + ); + await notify( + [ + "🐺 *AlphaClaw Watchdog*", + withViewLogsSuffix( + "🟡 Gateway is in safe mode — channel autostart suppressed by its crash-loop breaker", + ), + `Suppressed channels: ${suppressed.join(", ")}`, + "The gateway is up but these channels are not delivering messages. Resume them from the Watchdog tab once the crash cause is fixed.", + ].join("\n"), + correlationId, + "crash", + ); + return; + } + if (state.safeMode) { + clearSafeModeState(); + logEvent("safe_mode", source, "ok", { recovered: true }, correlationId); + await notify( + [ + "🐺 *AlphaClaw Watchdog*", + withViewLogsSuffix("🟢 Gateway safe mode cleared — channels resumed"), + ].join("\n"), + correlationId, + "recovery", + ); + } + }; + + const resumeChannels = async () => { + const correlationId = createCorrelationId(); + const channels = [...state.suppressedChannels]; + if (channels.length === 0) { + return { ok: false, skipped: true, reason: "no_suppressed_channels" }; + } + const results = []; + for (const channel of channels) { + const params = JSON.stringify({ channel }); + const result = await clawCmd( + `gateway call channels.start --params ${shellEscapeArg(params)}`, + { quiet: true }, + ); + const ok = !!result?.ok; + results.push({ channel, ok, stderr: ok ? undefined : result?.stderr }); + logEvent( + "safe_mode_resume", + "manual", + ok ? "ok" : "failed", + { channel, stderr: result?.stderr || null }, + correlationId, + ); + } + await runHealthCheck({ + source: "resume_channels", + allowAutoRepair: false, + allowDuringOperation: true, + }); + return { ok: results.every((entry) => entry.ok), results }; + }; + const updateSettings = ({ autoRepair, notificationsEnabled } = {}) => { const hasAutoRepair = typeof autoRepair === "boolean"; const hasNotificationsEnabled = typeof notificationsEnabled === "boolean"; @@ -573,6 +719,7 @@ const createWatchdog = ({ parsed.details || { ok: true }, correlationId, ); + await evaluateChannelSuppression(source, correlationId); return true; } if (restartWindowActive) { @@ -702,6 +849,7 @@ const createWatchdog = ({ } = {}) => { const correlationId = createCorrelationId(); clearDegradedHealthCheckTimer(); + clearSafeModeState(); if (expectedExit && (code == null || code === 0)) { state.lifecycle = "restarting"; state.health = "unknown"; @@ -742,7 +890,7 @@ const createWatchdog = ({ return; } - if (code === 78) { + if (code === kOpenclawConfigErrorExitCode) { state.configurationErrorActive = true; state.lifecycle = "configuration_error"; state.health = "unhealthy"; @@ -843,6 +991,7 @@ const createWatchdog = ({ const onExpectedRestart = () => { clearDegradedHealthCheckTimer(); + clearSafeModeState(); state.lifecycle = "restarting"; state.health = "unknown"; state.uptimeStartedAt = null; @@ -886,6 +1035,7 @@ const createWatchdog = ({ state.startupConsecutiveHealthFailures = 0; state.awaitingAutoRepairRecovery = false; state.pendingRecoveryNoticeSource = ""; + clearSafeModeState(); closeIncident(); }; @@ -906,6 +1056,8 @@ const createWatchdog = ({ crashLoopWindowMs: kWatchdogCrashLoopWindowMs, operationInProgress: state.operationInProgress, gatewayPid: state.gatewayPid, + safeMode: state.safeMode, + suppressedChannels: [...state.suppressedChannels], }; }; @@ -914,6 +1066,7 @@ const createWatchdog = ({ getSettings, updateSettings, triggerRepair, + resumeChannels, onExpectedRestart, onGatewayExit, onGatewayLaunch, diff --git a/package-lock.json b/package-lock.json index db86e1e6..c2c5f16a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { - "name": "@chrysb/alphaclaw", + "name": "alphaclaw", "version": "0.9.33", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@chrysb/alphaclaw", + "name": "alphaclaw", "version": "0.9.33", "license": "MIT", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.7.1", + "openclaw": "2026.7.1-2", "ws": "^8.19.0" }, "bin": { @@ -1039,18 +1039,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.19.0" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -2252,18 +2240,6 @@ "node": ">=8" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2549,9 +2525,9 @@ } }, "node_modules/openclaw": { - "version": "2026.7.1", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", - "integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==", + "version": "2026.7.1-2", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1-2.tgz", + "integrity": "sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==", "hasInstallScript": true, "hasShrinkwrap": true, "license": "MIT", @@ -2569,7 +2545,7 @@ "@mistralai/mistralai": "2.4.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", - "@openclaw/ai": "2026.7.1", + "@openclaw/ai": "2026.7.1-2", "@openclaw/fs-safe": "0.4.1", "@openclaw/proxyline": "0.3.3", "@silvia-odwyer/photon-node": "0.3.4", @@ -2974,9 +2950,9 @@ } }, "node_modules/openclaw/node_modules/@openclaw/ai": { - "version": "2026.7.1", - "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1.tgz", - "integrity": "sha512-FsKy5DXSHf4qyN8Huoz/10HZRgoEwLF4uk8UWaCafaIler+q5Fsl51HcrIqIrEe0S38OT7LOaxnR++MOshAlmw==", + "version": "2026.7.1-2", + "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1-2.tgz", + "integrity": "sha512-st+NH0cxlQqdbEur//yYqM7WlYBjeEBnop3cztJTSCONKjv6LNoGguI9cH65asZG94FdM/39z857isRGPnZvEw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.109.1", @@ -7224,15 +7200,6 @@ "node": ">= 0.6" } }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7962,24 +7929,6 @@ "optional": true } } - }, - "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/package.json b/package.json index fa5f739a..7e07244d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@chrysb/alphaclaw", + "name": "alphaclaw", "version": "0.9.33", "publishConfig": { "access": "public" @@ -28,12 +28,13 @@ "test:watch": "vitest", "test:watchdog": "vitest run tests/server/watchdog.test.js tests/server/watchdog-db.test.js tests/server/routes-watchdog.test.js", "test:coverage": "vitest run --coverage", - "prepack": "npm run build:ui" + "prepack": "npm run build:ui", + "prepare": "npm run build:ui" }, "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.7.1", + "openclaw": "2026.7.1-2", "ws": "^8.19.0" }, "devDependencies": { diff --git a/tests/bin/openclaw-config-restore.test.js b/tests/bin/openclaw-config-restore.test.js index 70e332db..951184ec 100644 --- a/tests/bin/openclaw-config-restore.test.js +++ b/tests/bin/openclaw-config-restore.test.js @@ -3,6 +3,7 @@ const os = require("os"); const path = require("path"); const { execSync } = require("child_process"); const { + ensureMainUpstream, restoreMissingOpenclawConfigFromRemote, } = require("../../lib/cli/openclaw-config-restore"); @@ -128,3 +129,144 @@ describe("restoreMissingOpenclawConfigFromRemote", () => { ); }); }); + +describe("restoreMissingOpenclawConfigFromRemote with injected modules", () => { + const makeFsModule = (overrides = {}) => ({ + existsSync: vi.fn(() => false), + writeFileSync: vi.fn(), + rmSync: vi.fn(), + ...overrides, + }); + const osModule = { tmpdir: () => "/fake-tmp" }; + + it("requires an openclawDir", () => { + expect(() => restoreMissingOpenclawConfigFromRemote({})).toThrow( + "openclawDir is required", + ); + }); + + it("writes a GIT_ASKPASS helper when a GitHub token exists and skips empty remote configs", () => { + const fsModule = makeFsModule(); + const logs = []; + const commands = []; + const execSyncImpl = vi.fn((command, options = {}) => { + commands.push({ command, options }); + if (command.includes("symbolic-ref")) return "release\n"; + if (command.startsWith("git show ")) return " \n"; + return ""; + }); + + const result = restoreMissingOpenclawConfigFromRemote({ + fsModule, + osModule, + execSyncImpl, + env: { GITHUB_TOKEN: "gh-token", PATH: "/usr/bin" }, + logger: { log: (message) => logs.push(message) }, + processId: 4242, + openclawDir: "/data/.openclaw", + }); + + expect(result).toEqual({ + restored: false, + skipped: true, + reason: "empty_remote", + branch: "release", + }); + const askPassPath = "/fake-tmp/alphaclaw-boot-git-askpass-4242.sh"; + expect(fsModule.writeFileSync).toHaveBeenCalledTimes(1); + expect(fsModule.writeFileSync).toHaveBeenCalledWith( + askPassPath, + expect.stringContaining("x-access-token"), + { mode: 0o700 }, + ); + const lsRemote = commands.find((entry) => entry.command.includes("ls-remote")); + expect(lsRemote.command).toContain("'release'"); + expect(lsRemote.options.env).toEqual( + expect.objectContaining({ + GITHUB_TOKEN: "gh-token", + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: askPassPath, + }), + ); + expect(fsModule.rmSync).toHaveBeenCalledWith(askPassPath, { force: true }); + expect(logs).toContain( + "[alphaclaw] Remote config restore skipped: remote config empty", + ); + }); + + it("falls back to the main branch and reports errors even when askpass cleanup fails", () => { + const fsModule = makeFsModule({ + rmSync: vi.fn(() => { + throw new Error("rm failed"); + }), + }); + const execSyncImpl = vi.fn(() => { + throw new Error("network down"); + }); + const logs = []; + + const result = restoreMissingOpenclawConfigFromRemote({ + fsModule, + osModule, + execSyncImpl, + env: { GITHUB_TOKEN: "gh-token" }, + logger: { log: (message) => logs.push(message) }, + processId: 7, + openclawDir: "/data/.openclaw", + }); + + expect(result).toMatchObject({ + restored: false, + skipped: true, + reason: "error", + branch: "main", + }); + expect(result.error).toBeInstanceOf(Error); + expect(fsModule.rmSync).toHaveBeenCalled(); + expect(logs.some((message) => message.includes("network down"))).toBe(true); + }); +}); + +describe("ensureMainUpstream", () => { + it("returns false when main already has an upstream", () => { + const execSyncImpl = vi.fn(() => ""); + + expect(ensureMainUpstream({ execSyncImpl, openclawDir: "/d" })).toBe(false); + expect(execSyncImpl).toHaveBeenCalledTimes(2); + }); + + it("sets origin/main as the upstream when missing", () => { + const gitEnv = { GIT_ASKPASS: "/tmp/askpass.sh" }; + const execSyncImpl = vi.fn((command) => { + if (command.includes("rev-parse")) throw new Error("no upstream"); + return ""; + }); + + expect(ensureMainUpstream({ execSyncImpl, openclawDir: "/d", gitEnv })).toBe( + true, + ); + expect(execSyncImpl).toHaveBeenCalledWith( + "git branch --set-upstream-to=origin/main main", + expect.objectContaining({ cwd: "/d", env: gitEnv }), + ); + }); + + it("returns false when the main branch does not exist", () => { + const execSyncImpl = vi.fn((command) => { + if (command.includes("show-ref")) throw new Error("no main branch"); + return ""; + }); + + expect(ensureMainUpstream({ execSyncImpl, openclawDir: "/d" })).toBe(false); + expect(execSyncImpl).toHaveBeenCalledTimes(1); + }); + + it("returns false when setting the upstream fails", () => { + const execSyncImpl = vi.fn((command) => { + if (command.includes("show-ref")) return ""; + throw new Error("cannot set upstream"); + }); + + expect(ensureMainUpstream({ execSyncImpl, openclawDir: "/d" })).toBe(false); + }); +}); diff --git a/tests/frontend/api-cache.test.js b/tests/frontend/api-cache.test.js new file mode 100644 index 00000000..2be0c4dd --- /dev/null +++ b/tests/frontend/api-cache.test.js @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cachedFetch, + getCached, + invalidateCache, + setCached, +} from "../../lib/public/js/lib/api-cache.js"; + +// The cache is module-level state, so every test uses its own key. + +describe("frontend/api-cache", () => { + it("gets, sets, and invalidates cache entries", () => { + expect(getCached("")).toBe(null); + expect(getCached("never-set")).toBe(null); + + expect(setCached("basic-key", { a: 1 })).toEqual({ a: 1 }); + expect(getCached("basic-key")).toEqual({ a: 1 }); + + // Empty keys are no-ops that pass data through. + expect(setCached("", "ignored")).toBe("ignored"); + expect(getCached("")).toBe(null); + + invalidateCache("basic-key"); + expect(getCached("basic-key")).toBe(null); + expect(invalidateCache("")).toBeUndefined(); + }); + + it("bypasses caching for empty keys or non-function fetchers", async () => { + const fetcher = vi.fn(async () => "direct"); + await expect(cachedFetch("", fetcher)).resolves.toBe("direct"); + expect(fetcher).toHaveBeenCalledTimes(1); + + await expect(cachedFetch("bad-fetcher-key", null)).rejects.toThrow(); + }); + + it("returns fresh entries without refetching", async () => { + setCached("fresh-key", "cached-value"); + const fetcher = vi.fn(async () => "new-value"); + await expect( + cachedFetch("fresh-key", fetcher, { maxAgeMs: 60000 }), + ).resolves.toBe("cached-value"); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("serves stale data while revalidating in the background once", async () => { + setCached("swr-key", "stale-value"); + let resolveFetch; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const onRevalidate = vi.fn(); + + await expect( + cachedFetch("swr-key", fetcher, { maxAgeMs: 0, onRevalidate }), + ).resolves.toBe("stale-value"); + + // A second stale read while the revalidation is in flight does not + // schedule another fetch. + await expect( + cachedFetch("swr-key", fetcher, { maxAgeMs: 0 }), + ).resolves.toBe("stale-value"); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + + resolveFetch("fresh-value"); + await vi.waitFor(() => + expect(onRevalidate).toHaveBeenCalledWith("fresh-value"), + ); + expect(getCached("swr-key")).toBe("fresh-value"); + }); + + it("revalidates stale data without an onRevalidate callback", async () => { + setCached("swr-silent-key", "old"); + const fetcher = vi.fn(async () => "new"); + + await expect( + cachedFetch("swr-silent-key", fetcher, { maxAgeMs: 0 }), + ).resolves.toBe("old"); + await vi.waitFor(() => expect(getCached("swr-silent-key")).toBe("new")); + }); + + it("refetches stale entries when staleWhileRevalidate is off", async () => { + setCached("no-swr-key", "old"); + const fetcher = vi.fn(async () => "new"); + + await expect( + cachedFetch("no-swr-key", fetcher, { + maxAgeMs: 0, + staleWhileRevalidate: false, + }), + ).resolves.toBe("new"); + expect(getCached("no-swr-key")).toBe("new"); + }); + + it("forces a refetch past a fresh cache entry", async () => { + setCached("force-key", "old"); + const fetcher = vi.fn(async () => "forced"); + + await expect( + cachedFetch("force-key", fetcher, { maxAgeMs: 60000, force: true }), + ).resolves.toBe("forced"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("deduplicates concurrent uncached fetches", async () => { + let resolveFetch; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const otherFetcher = vi.fn(async () => "should-not-run"); + + const firstPromise = cachedFetch("inflight-key", fetcher); + const secondPromise = cachedFetch("inflight-key", otherFetcher); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + resolveFetch("shared-result"); + + await expect(firstPromise).resolves.toBe("shared-result"); + await expect(secondPromise).resolves.toBe("shared-result"); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(otherFetcher).not.toHaveBeenCalled(); + expect(getCached("inflight-key")).toBe("shared-result"); + }); +}); diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 5f982b06..b5c0853b 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -656,3 +656,680 @@ describe("frontend/api", () => { expect(result).toEqual({ ok: true }); }); }); + +const mockTextResponse = (status, text) => ({ + status, + ok: status >= 200 && status < 300, + text: async () => text, +}); + +class FakeEventSource { + static instances = []; + + constructor(url, options) { + this.url = url; + this.options = options; + this.listeners = new Map(); + this.closed = false; + this.onopen = undefined; + this.onerror = undefined; + FakeEventSource.instances.push(this); + } + + addEventListener(type, handler) { + const handlers = this.listeners.get(type) || []; + handlers.push(handler); + this.listeners.set(type, handlers); + } + + removeEventListener(type, handler) { + const handlers = (this.listeners.get(type) || []).filter( + (entry) => entry !== handler, + ); + this.listeners.set(type, handlers); + } + + close() { + this.closed = true; + } + + emit(type, event = {}) { + for (const handler of this.listeners.get(type) || []) handler(event); + } +} + +describe("frontend/api endpoint wrapper coverage", () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse(200, { ok: true })); + global.window = { location: { href: "http://localhost/" } }; + }); + + afterEach(() => { + delete global.fetch; + delete global.window; + }); + + const kWrapperCases = [ + ["fetchPairings", [], "/api/pairings", undefined], + ["approvePairing", ["p1", "telegram", "acct"], "/api/pairings/p1/approve", "POST"], + ["rejectPairing", ["p1", "telegram"], "/api/pairings/p1/reject", "POST"], + ["fetchGoogleAccounts", [], "/api/google/accounts", undefined], + ["fetchGoogleStatus", [], "/api/google/status", undefined], + ["fetchGoogleStatus", ["acct-1"], "/api/google/status?accountId=acct-1", undefined], + ["fetchGoogleCredentials", [], "/api/google/credentials", undefined], + [ + "fetchGoogleCredentials", + [{ accountId: "a", client: "gmail" }], + "/api/google/credentials?accountId=a&client=gmail", + undefined, + ], + ["checkGoogleApis", ["a"], "/api/google/check?accountId=a", undefined], + [ + "saveGoogleCredentials", + [{ clientId: "id", clientSecret: "sec", email: "e@x.com" }], + "/api/google/credentials", + "POST", + ], + ["saveGoogleAccount", [{ email: "e@x.com" }], "/api/google/accounts", "POST"], + ["disconnectGoogle", ["a"], "/api/google/disconnect", "POST"], + ["fetchGmailConfig", [], "/api/gmail/config", undefined], + ["saveGmailConfig", [], "/api/gmail/config", "POST"], + ["startGmailWatch", ["acct"], "/api/gmail/watch/start", "POST"], + ["stopGmailWatch", ["acct"], "/api/gmail/watch/stop", "POST"], + ["renewGmailWatch", [], "/api/gmail/watch/renew", "POST"], + ["fetchAgentSessions", [], "/api/agent/sessions", undefined], + ["fetchDoctorRuns", [5], "/api/doctor/runs?limit=5", undefined], + ["fetchDoctorCards", [{ runId: "" }], "/api/doctor/cards", undefined], + ["fetchDoctorRun", ["r1"], "/api/doctor/runs/r1", undefined], + ["fetchDoctorRunCards", ["r1"], "/api/doctor/runs/r1/cards", undefined], + [ + "updateDoctorCardStatus", + [{ cardId: "c1", status: "resolved" }], + "/api/doctor/cards/c1/status", + "POST", + ], + ["sendAgentMessage", [{ message: "hi", sessionKey: "k" }], "/api/agent/message", "POST"], + ["sendAgentMessage", [], "/api/agent/message", "POST"], + ["sendDoctorCardFix", [], "/api/doctor/findings//fix", "POST"], + ["restartGateway", [], "/api/gateway/restart", "POST"], + ["fetchRestartStatus", [], "/api/restart-status", undefined], + ["dismissRestartStatus", [], "/api/restart-status/dismiss", "POST"], + ["fetchWatchdogStatus", [], "/api/watchdog/status", undefined], + ["fetchUsageSummary", [], "/api/usage/summary?days=30", undefined], + ["fetchUsageSessions", [], "/api/usage/sessions?limit=50", undefined], + [ + "fetchUsageSessionTimeSeries", + ["s1", 50], + "/api/usage/sessions/s1/timeseries?maxPoints=50", + undefined, + ], + ["fetchWatchdogEvents", [], "/api/watchdog/events?limit=20", undefined], + ["createWatchdogTerminalSession", [], "/api/watchdog/terminal/session", "POST"], + [ + "fetchWatchdogTerminalOutput", + ["s1", 5], + "/api/watchdog/terminal/output?sessionId=s1&cursor=5", + undefined, + ], + [ + "fetchWatchdogTerminalOutput", + ["s1"], + "/api/watchdog/terminal/output?sessionId=s1&cursor=0", + undefined, + ], + ["sendWatchdogTerminalInput", ["s1", "ls"], "/api/watchdog/terminal/input", "POST"], + ["closeWatchdogTerminalSession", ["s1"], "/api/watchdog/terminal/close", "POST"], + ["triggerWatchdogRepair", [], "/api/watchdog/repair", "POST"], + ["fetchWatchdogResources", [], "/api/watchdog/resources", undefined], + ["fetchWatchdogSettings", [], "/api/watchdog/settings", undefined], + ["updateWatchdogSettings", [{ enabled: true }], "/api/watchdog/settings", "PUT"], + ["updateWatchdogSettings", [null], "/api/watchdog/settings", "PUT"], + ["fetchDashboardUrl", [], "/api/gateway/dashboard", undefined], + ["fetchAlphaclawVersion", [], "/api/alphaclaw/version", undefined], + ["fetchAlphaclawVersion", [true], "/api/alphaclaw/version?refresh=1", undefined], + ["updateAlphaclaw", [], "/api/alphaclaw/update", "POST"], + ["fetchSyncCron", [], "/api/sync-cron", undefined], + ["updateSyncCron", [{ schedule: "0 0 * * *" }], "/api/sync-cron", "PUT"], + [ + "updateOpenAiCompatApiFeature", + [true], + "/api/alphaclaw/config/features/openai-compat-api", + "PUT", + ], + ["fetchCronJobs", [], "/api/cron/jobs?sortBy=nextRunAtMs&sortDir=asc", undefined], + ["fetchCronJobs", [{ sortBy: "", sortDir: "" }], "/api/cron/jobs", undefined], + ["fetchCronStatus", [], "/api/cron/status", undefined], + [ + "fetchCronJobRuns", + ["j1"], + "/api/cron/jobs/j1/runs?limit=20&offset=0&status=all&deliveryStatus=all&sortDir=desc", + undefined, + ], + [ + "fetchCronJobRuns", + ["j1", { query: " find me " }], + "/api/cron/jobs/j1/runs?limit=20&offset=0&status=all&deliveryStatus=all&sortDir=desc&query=find+me", + undefined, + ], + ["fetchCronJobUsage", ["j1"], "/api/cron/jobs/j1/usage?days=30", undefined], + ["fetchCronJobTrends", ["j1"], "/api/cron/jobs/j1/trends?range=7d", undefined], + ["fetchCronBulkUsage", [], "/api/cron/usage/bulk?days=30", undefined], + [ + "fetchCronBulkRuns", + [], + "/api/cron/runs/bulk?sinceMs=0&limitPerJob=20&status=all&deliveryStatus=all&sortDir=desc", + undefined, + ], + ["triggerCronJobRun", ["j1"], "/api/cron/jobs/j1/run", "POST"], + ["setCronJobEnabled", ["j1", true], "/api/cron/jobs/j1/enable", "POST"], + ["setCronJobEnabled", ["j1", false], "/api/cron/jobs/j1/disable", "POST"], + ["updateCronJobPrompt", ["j1", "new prompt"], "/api/cron/jobs/j1/prompt", "PUT"], + ["updateCronJobRouting", ["j1"], "/api/cron/jobs/j1/routing", "PUT"], + ["fetchDevicePairings", [], "/api/devices", undefined], + ["rejectDevice", ["d1"], "/api/devices/d1/reject", "POST"], + ["fetchNodesStatus", [], "/api/nodes", undefined], + ["approveNode", ["n1"], "/api/nodes/n1/approve", "POST"], + ["removeNode", ["n1"], "/api/nodes/n1", "DELETE"], + ["routeExecToNode", ["n1"], "/api/nodes/n1/route", "POST"], + ["fetchNodeConnectInfo", [], "/api/nodes/connect-info", undefined], + [ + "fetchNodeBrowserStatusForNode", + ["n1"], + "/api/nodes/n1/browser-status?profile=user", + undefined, + ], + ["fetchNodeExecConfig", [], "/api/nodes/exec-config", undefined], + ["saveNodeExecConfig", [{ security: "allowlist" }], "/api/nodes/exec-config", "POST"], + ["fetchNodeExecApprovals", [], "/api/nodes/exec-approvals", undefined], + [ + "addNodeExecAllowlistPattern", + ["npm run *"], + "/api/nodes/exec-approvals/allowlist", + "POST", + ], + [ + "removeNodeExecAllowlistPattern", + ["e1"], + "/api/nodes/exec-approvals/allowlist/e1", + "DELETE", + ], + ["fetchAuthStatus", [], "/api/auth/status", undefined], + ["logout", [], "/api/auth/logout", "POST"], + ["fetchOnboardStatus", [], "/api/onboard/status", undefined], + ["fetchModels", [], "/api/models", undefined], + ["fetchModelStatus", [], "/api/models/status", undefined], + [ + "fetchThinkingOptions", + ["anthropic/claude"], + "/api/models/thinking-options?modelKey=anthropic%2Fclaude", + undefined, + ], + ["setPrimaryModel", ["k1"], "/api/models/set", "POST"], + ["fetchModelsConfig", [], "/api/models/config", undefined], + ["fetchModelsConfig", [{ agentId: "a1" }], "/api/models/config?agentId=a1", undefined], + ["saveModelsConfig", [], "/api/models/config", "PUT"], + [ + "saveModelsConfig", + [{ agentId: "a1", primary: "k" }], + "/api/models/config?agentId=a1", + "PUT", + ], + ["fetchAuthProfiles", [], "/api/models/auth", undefined], + ["upsertAuthProfile", ["p1", { apiKey: "sk" }], "/api/models/auth/p1", "PUT"], + ["deleteAuthProfile", ["p1"], "/api/models/auth/p1", "DELETE"], + ["fetchAgents", [], "/api/agents", undefined], + ["fetchChannelAccounts", [], "/api/channels/accounts", undefined], + [ + "fetchChannelAccountToken", + [{ provider: "telegram" }], + "/api/channels/accounts/token?provider=telegram&accountId=default", + undefined, + ], + [ + "fetchChannelAccountToken", + [], + "/api/channels/accounts/token?provider=&accountId=default", + undefined, + ], + ["createChannelAccountJob", [{ provider: "telegram" }], "/api/channels/accounts/jobs", "POST"], + ["runChannelAccountLogin", [{ provider: "whatsapp" }], "/api/channels/accounts/login", "POST"], + [ + "fetchChannelAccountLoginStatus", + [{ provider: "whatsapp" }], + "/api/channels/accounts/login-status?provider=whatsapp&accountId=default", + undefined, + ], + [ + "fetchChannelAccountLoginStatus", + [], + "/api/channels/accounts/login-status?provider=&accountId=default", + undefined, + ], + ["fetchAgent", ["a1"], "/api/agents/a1", undefined], + ["fetchAgentWorkspaceSize", ["a1"], "/api/agents/a1/workspace-size", undefined], + ["fetchAgentBindings", ["a1"], "/api/agents/a1/bindings", undefined], + ["createAgent", [{ name: "Ops" }], "/api/agents", "POST"], + ["updateAgent", ["a1", { name: "Ops" }], "/api/agents/a1", "PUT"], + ["addAgentBinding", ["a1", { channel: "telegram" }], "/api/agents/a1/bindings", "POST"], + ["removeAgentBinding", ["a1", { channel: "telegram" }], "/api/agents/a1/bindings", "DELETE"], + ["deleteAgent", ["a1"], "/api/agents/a1?keepWorkspace=true", "DELETE"], + [ + "deleteAgent", + ["a1", { keepWorkspace: false }], + "/api/agents/a1?keepWorkspace=false", + "DELETE", + ], + ["setDefaultAgent", ["a1"], "/api/agents/a1/default", "POST"], + ["fetchCodexStatus", [], "/api/codex/status", undefined], + ["disconnectCodex", [], "/api/codex/disconnect", "POST"], + ["exchangeCodexOAuth", ["code-1"], "/api/codex/exchange", "POST"], + ["fetchEnvVars", [], "/api/env", undefined], + ["saveEnvVars", [[{ key: "A", value: "1" }]], "/api/env", "PUT"], + ["fetchWebhooks", [], "/api/webhooks", undefined], + ["fetchWebhookDetail", ["hook"], "/api/webhooks/hook", undefined], + ["createWebhook", ["hook"], "/api/webhooks", "POST"], + ["deleteWebhook", ["hook"], "/api/webhooks/hook", "DELETE"], + ["updateWebhookDestination", ["hook"], "/api/webhooks/hook/destination", "PUT"], + ["createWebhookOauthCallback", ["hook"], "/api/webhooks/hook/oauth-callback", "POST"], + ["rotateWebhookOauthCallback", ["hook"], "/api/webhooks/hook/oauth-callback/rotate", "POST"], + ["deleteWebhookOauthCallback", ["hook"], "/api/webhooks/hook/oauth-callback", "DELETE"], + [ + "fetchWebhookRequests", + ["hook"], + "/api/webhooks/hook/requests?limit=50&offset=0&status=all", + undefined, + ], + ["fetchWebhookRequest", ["hook", 3], "/api/webhooks/hook/requests/3", undefined], + ["fetchFileContent", ["notes/a.txt"], "/api/browse/read?path=notes%2Fa.txt", undefined], + ["saveFileContent", ["notes/a.txt", "hello"], "/api/browse/write", "PUT"], + ["saveFileContent", ["notes/a.txt", null], "/api/browse/write", "PUT"], + ["createBrowseFile", ["notes/new.txt"], "/api/browse/create-file", "POST"], + ["createBrowseFolder", ["notes/dir"], "/api/browse/create-folder", "POST"], + ["moveBrowsePath", ["a.txt", "b.txt"], "/api/browse/move", "POST"], + ["deleteBrowseFile", ["a.txt"], "/api/browse/delete", "DELETE"], + ["restoreBrowseFile", ["a.txt"], "/api/browse/restore", "POST"], + ["fetchBrowseGitSummary", [], "/api/browse/git-summary", undefined], + [ + "fetchBrowseSqliteTable", + [{ filePath: "db.sqlite", table: "runs" }], + "/api/browse/sqlite-table?path=db.sqlite&table=runs&limit=50&offset=0", + undefined, + ], + ]; + + it.each(kWrapperCases)( + "%s requests %s", + async (name, args, expectedUrl, method) => { + const api = await loadApiModule(); + + const result = await api[name](...args); + + const [calledUrl, options = {}] = global.fetch.mock.calls[0]; + expect(calledUrl).toBe(expectedUrl); + expect(options.method).toBe(method); + expect(options.headers).toBeInstanceOf(Headers); + expect(result).toEqual({ ok: true }); + }, + ); +}); + +describe("frontend/api behaviors", () => { + const kRealIntl = global.Intl; + + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse(200, { ok: true })); + global.window = { location: { href: "http://localhost/" } }; + FakeEventSource.instances = []; + }); + + afterEach(() => { + global.Intl = kRealIntl; + delete global.fetch; + delete global.window; + delete global.document; + }); + + it("authFetch attaches the browser timezone header", async () => { + const api = await loadApiModule(); + + const res = await api.authFetch("/api/ping"); + + expect(res.status).toBe(200); + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.get("x-client-timezone")).toBe( + new Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + }); + + it("authFetch keeps a caller-provided timezone header", async () => { + const api = await loadApiModule(); + + await api.authFetch("/api/ping", { + headers: { "x-client-timezone": "UTC" }, + }); + + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.get("x-client-timezone")).toBe("UTC"); + }); + + it("authFetch omits the timezone header when Intl lookup throws", async () => { + global.Intl = { + DateTimeFormat: () => { + throw new Error("boom"); + }, + }; + const api = await loadApiModule(); + + await api.authFetch("/api/ping"); + + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.get("x-client-timezone")).toBe(null); + }); + + it("authFetch omits the timezone header when timezone is empty", async () => { + global.Intl = { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: "" }) }), + }; + const api = await loadApiModule(); + + await api.authFetch("/api/ping"); + + const headers = global.fetch.mock.calls[0][1].headers; + expect(headers.get("x-client-timezone")).toBe(null); + }); + + it("still redirects on 401 when localStorage.clear throws", async () => { + global.window.localStorage = { + clear: () => { + throw new Error("denied"); + }, + }; + global.fetch.mockResolvedValue(mockJsonResponse(401, {})); + const api = await loadApiModule(); + + await expect(api.fetchStatus()).rejects.toThrow("Unauthorized"); + expect(window.location.href).toBe("/setup"); + }); + + it("subscribeStatusEvents throws when EventSource is unavailable", async () => { + const api = await loadApiModule(); + + expect(() => api.subscribeStatusEvents()).toThrow( + "Server events are not supported in this browser", + ); + }); + + it("subscribeStatusEvents wires status events and unsubscribes", async () => { + global.window.EventSource = FakeEventSource; + const api = await loadApiModule(); + const events = []; + const onOpen = vi.fn(); + const onError = vi.fn(); + + const unsubscribe = api.subscribeStatusEvents({ + onMessage: (payload) => events.push(payload), + onOpen, + onError, + }); + + const source = FakeEventSource.instances[0]; + expect(source.url).toBe("/api/events/status"); + expect(source.options).toEqual({ withCredentials: true }); + + source.emit("status", { data: JSON.stringify({ gateway: "running" }) }); + source.emit("status", { data: "not json" }); + source.emit("status", { data: "null" }); + source.emit("status", {}); + source.onopen(); + source.onerror("err"); + + expect(events).toEqual([{ gateway: "running" }, {}, {}, {}]); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith("err"); + + unsubscribe(); + expect(source.closed).toBe(true); + expect(source.onopen).toBe(null); + expect(source.onerror).toBe(null); + source.emit("status", { data: "{}" }); + expect(events).toHaveLength(4); + }); + + it("subscribeStatusEvents defaults its callbacks to no-ops", async () => { + global.window.EventSource = FakeEventSource; + const api = await loadApiModule(); + + const unsubscribe = api.subscribeStatusEvents({}); + + const source = FakeEventSource.instances[0]; + expect(() => { + source.emit("status", { data: "{}" }); + source.onopen(); + source.onerror("err"); + }).not.toThrow(); + unsubscribe(); + }); + + it("subscribeOperationEvents subscribes to the operation SSE stream", async () => { + global.window.EventSource = FakeEventSource; + const api = await loadApiModule(); + const messages = []; + + const unsubscribe = api.subscribeOperationEvents({ + operationId: "op 1", + onMessage: (message) => messages.push(message), + }); + + const source = FakeEventSource.instances[0]; + expect(source.url).toBe("/api/operations/op%201/events"); + source.emit("phase", { data: JSON.stringify({ phase: "start" }) }); + expect(messages).toEqual([{ event: "phase", data: { phase: "start" } }]); + unsubscribe(); + expect(source.closed).toBe(true); + }); + + it("parseJsonOrThrow rejects when the payload marks ok false", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: false, error: "nope" })); + const api = await loadApiModule(); + + await expect(api.rejectPairing("p1", "telegram")).rejects.toThrow("nope"); + }); + + it("parseJsonOrThrow resolves an empty body to an empty object", async () => { + global.fetch.mockResolvedValue(mockTextResponse(200, "")); + const api = await loadApiModule(); + + await expect(api.rejectPairing("p1", "telegram")).resolves.toEqual({}); + }); + + it("parseJsonOrThrow rejects with raw text for invalid JSON", async () => { + global.fetch.mockResolvedValue(mockTextResponse(200, "garbage")); + const api = await loadApiModule(); + + await expect(api.rejectPairing("p1", "telegram")).rejects.toThrow("garbage"); + }); + + it("parseJsonOrThrow falls back to HTTP status errors", async () => { + global.fetch.mockResolvedValue(mockTextResponse(500, "")); + const api = await loadApiModule(); + + await expect(api.rejectPairing("p1", "telegram")).rejects.toThrow("HTTP 500"); + }); + + it("fetchWatchdogLogs returns raw text", async () => { + global.fetch.mockResolvedValue(mockTextResponse(200, "log text")); + const api = await loadApiModule(); + + await expect(api.fetchWatchdogLogs(1024)).resolves.toBe("log text"); + expect(global.fetch.mock.calls[0][0]).toBe("/api/watchdog/logs?tail=1024"); + }); + + it("fetchWatchdogLogs throws on non-OK responses", async () => { + global.fetch.mockResolvedValue(mockTextResponse(500, "boom")); + const api = await loadApiModule(); + + await expect(api.fetchWatchdogLogs()).rejects.toThrow( + "Could not load watchdog logs", + ); + }); + + it("routeExecToNode maps AbortError to a timeout message", async () => { + global.fetch.mockRejectedValue( + Object.assign(new Error("aborted"), { name: "AbortError" }), + ); + const api = await loadApiModule(); + + await expect(api.routeExecToNode("n1")).rejects.toThrow( + "Routing timed out. Gateway may be restarting or unavailable.", + ); + }); + + it("routeExecToNode rethrows other errors", async () => { + global.fetch.mockRejectedValue(new Error("network down")); + const api = await loadApiModule(); + + await expect(api.routeExecToNode("n1")).rejects.toThrow("network down"); + }); + + it("downloadBrowseFile throws with server error text", async () => { + global.fetch.mockResolvedValue(mockTextResponse(404, "missing file")); + const api = await loadApiModule(); + + await expect(api.downloadBrowseFile("a.txt")).rejects.toThrow("missing file"); + }); + + it("downloadBrowseFile throws when object URLs are unsupported", async () => { + global.window.URL = { createObjectURL: null }; + global.fetch.mockResolvedValue({ + status: 200, + ok: true, + blob: async () => new Blob(["x"]), + text: async () => "", + }); + const api = await loadApiModule(); + + await expect(api.downloadBrowseFile("a.txt")).rejects.toThrow( + "Download is not supported in this browser", + ); + }); + + it("fetchAlphaclawReleaseNotes returns server release notes with a tag query", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, tag: "v1" })); + const api = await loadApiModule(); + + const result = await api.fetchAlphaclawReleaseNotes("v1"); + + expect(global.fetch.mock.calls[0][0]).toBe("/api/alphaclaw/release-notes?tag=v1"); + expect(result).toEqual({ ok: true, tag: "v1" }); + }); + + it("fetchAlphaclawReleaseNotes falls back to the GitHub tag endpoint", async () => { + global.fetch + .mockResolvedValueOnce(mockTextResponse(500, JSON.stringify({ error: "nope" }))) + .mockResolvedValueOnce( + mockTextResponse( + 200, + JSON.stringify({ + tag_name: "v2", + name: "Release 2", + body: "Notes", + html_url: "https://example.com/v2", + published_at: "2026-01-01T00:00:00Z", + }), + ), + ); + const api = await loadApiModule(); + + const result = await api.fetchAlphaclawReleaseNotes("v2"); + + expect(global.fetch.mock.calls[1][0]).toBe( + "https://api.github.com/repos/chrysb/alphaclaw/releases/tags/v2", + ); + expect(result).toEqual({ + ok: true, + tag: "v2", + name: "Release 2", + body: "Notes", + htmlUrl: "https://example.com/v2", + publishedAt: "2026-01-01T00:00:00Z", + }); + }); + + it("fetchAlphaclawReleaseNotes falls back to the latest release endpoint", async () => { + global.fetch + .mockResolvedValueOnce(mockTextResponse(500, "boom")) + .mockResolvedValueOnce(mockTextResponse(200, "")); + const api = await loadApiModule(); + + const result = await api.fetchAlphaclawReleaseNotes(); + + expect(global.fetch.mock.calls[1][0]).toBe( + "https://api.github.com/repos/chrysb/alphaclaw/releases/latest", + ); + expect(result).toEqual({ + ok: true, + tag: "", + name: "", + body: "", + htmlUrl: "", + publishedAt: "", + }); + }); + + it("fetchAlphaclawReleaseNotes surfaces raw fallback text errors", async () => { + global.fetch + .mockResolvedValueOnce(mockTextResponse(500, "boom")) + .mockResolvedValueOnce(mockTextResponse(500, "oops")); + const api = await loadApiModule(); + + await expect(api.fetchAlphaclawReleaseNotes()).rejects.toThrow("oops"); + }); + + it("fetchAlphaclawReleaseNotes surfaces GitHub error messages", async () => { + global.fetch + .mockResolvedValueOnce(mockTextResponse(500, "boom")) + .mockResolvedValueOnce( + mockTextResponse(403, JSON.stringify({ message: "rate limited" })), + ); + const api = await loadApiModule(); + + await expect(api.fetchAlphaclawReleaseNotes()).rejects.toThrow("rate limited"); + }); + + it("fetchSyncCron throws on invalid JSON and API errors", async () => { + const api = await loadApiModule(); + + global.fetch.mockResolvedValue(mockTextResponse(200, "garbage")); + await expect(api.fetchSyncCron()).rejects.toThrow("garbage"); + + global.fetch.mockResolvedValue(mockTextResponse(400, JSON.stringify({ error: "bad" }))); + await expect(api.fetchSyncCron()).rejects.toThrow("bad"); + }); + + it("updateSyncCron throws on invalid JSON and API errors", async () => { + const api = await loadApiModule(); + + global.fetch.mockResolvedValue(mockTextResponse(200, "garbage")); + await expect(api.updateSyncCron({})).rejects.toThrow("garbage"); + + global.fetch.mockResolvedValue(mockTextResponse(400, JSON.stringify({ error: "bad" }))); + await expect(api.updateSyncCron({})).rejects.toThrow("bad"); + }); + + it("updateOpenAiCompatApiFeature throws on invalid JSON and API errors", async () => { + const api = await loadApiModule(); + + global.fetch.mockResolvedValue(mockTextResponse(200, "garbage")); + await expect(api.updateOpenAiCompatApiFeature(true)).rejects.toThrow("garbage"); + + global.fetch.mockResolvedValue(mockTextResponse(400, JSON.stringify({ error: "bad" }))); + await expect(api.updateOpenAiCompatApiFeature(false)).rejects.toThrow("bad"); + }); + + it("saveEnvVars throws raw text for invalid JSON responses", async () => { + global.fetch.mockResolvedValue(mockTextResponse(200, "garbage")); + const api = await loadApiModule(); + + await expect(api.saveEnvVars([])).rejects.toThrow("garbage"); + }); +}); diff --git a/tests/frontend/channel-create-operation-more.test.js b/tests/frontend/channel-create-operation-more.test.js new file mode 100644 index 00000000..2916c70b --- /dev/null +++ b/tests/frontend/channel-create-operation-more.test.js @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../lib/public/js/lib/api.js", () => ({ + createChannelAccount: vi.fn(), + createChannelAccountJob: vi.fn(), + subscribeOperationEvents: vi.fn(), +})); + +import { createChannelAccountWithProgress } from "../../lib/public/js/lib/channel-create-operation.js"; +import { + createChannelAccountJob, + subscribeOperationEvents, +} from "../../lib/public/js/lib/api.js"; + +describe("frontend/channel-create-operation (extended)", () => { + let handlers = null; + let close = null; + + beforeEach(() => { + global.window = { EventSource: function EventSource() {} }; + handlers = null; + close = vi.fn(); + subscribeOperationEvents.mockImplementation((nextHandlers) => { + handlers = nextHandlers; + return close; + }); + createChannelAccountJob.mockResolvedValue({ operationId: "op-x" }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete global.window; + }); + + it("throws when the job start returns no operation id", async () => { + createChannelAccountJob.mockResolvedValue({}); + await expect( + createChannelAccountWithProgress({ payload: {}, onPhase: vi.fn() }), + ).rejects.toThrow("Could not start channel creation operation"); + }); + + it("ignores phases without labels and replaces deferred phases", async () => { + vi.useFakeTimers(); + const onPhase = vi.fn(); + const operationPromise = createChannelAccountWithProgress({ + payload: {}, + onPhase, + }); + await Promise.resolve(); + expect(handlers).toBeTruthy(); + + // Unknown events and label-less phases are ignored. + handlers.onMessage({ event: "unknown-event", data: {} }); + handlers.onMessage({ event: "phase", data: { phase: "x", label: " " } }); + expect(onPhase.mock.calls.map((call) => call[0])).toEqual(["Loading..."]); + + handlers.onMessage({ + event: "phase", + data: { phase: "restarting", label: "Restarting gateway..." }, + }); + // Two updates inside the restart minimum-visibility window: the second + // deferral must clear the first deferred timer. + handlers.onMessage({ + event: "phase", + data: { phase: "step-1", label: "Step 1" }, + }); + handlers.onMessage({ + event: "phase", + data: { phase: "step-2", label: "Step 2" }, + }); + vi.advanceTimersByTime(1200); + expect(onPhase.mock.calls.map((call) => call[0])).toEqual([ + "Loading...", + "Restarting gateway...", + "Step 2", + ]); + + handlers.onMessage({ event: "done", data: { ok: true } }); + await expect(operationPromise).resolves.toEqual({ ok: true }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("ignores error events after the operation already resolved", async () => { + const operationPromise = createChannelAccountWithProgress({ + payload: {}, + onPhase: vi.fn(), + }); + await Promise.resolve(); + + handlers.onMessage({ event: "done", data: { created: true } }); + handlers.onMessage({ event: "error", data: { error: "too late" } }); + + await expect(operationPromise).resolves.toEqual({ created: true }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("rejects with a fallback message when the error payload is empty", async () => { + const operationPromise = createChannelAccountWithProgress({ + payload: {}, + onPhase: vi.fn(), + }); + await Promise.resolve(); + + handlers.onMessage({ event: "error", data: {} }); + await expect(operationPromise).rejects.toThrow( + "Could not create channel", + ); + }); + + it("rejects when the stream disconnects before settling", async () => { + const operationPromise = createChannelAccountWithProgress({ + payload: {}, + onPhase: vi.fn(), + }); + await Promise.resolve(); + + handlers.onError(); + await expect(operationPromise).rejects.toThrow( + "Channel operation stream disconnected", + ); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("resolves done events with a default payload object", async () => { + const operationPromise = createChannelAccountWithProgress({ + payload: {}, + onPhase: vi.fn(), + }); + await Promise.resolve(); + + handlers.onMessage({ event: "done" }); + await expect(operationPromise).resolves.toEqual({}); + }); +}); diff --git a/tests/frontend/cron-calendar-helpers-more.test.js b/tests/frontend/cron-calendar-helpers-more.test.js new file mode 100644 index 00000000..36a748d4 --- /dev/null +++ b/tests/frontend/cron-calendar-helpers-more.test.js @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + buildSlotKey, + buildTokenTierByJobId, + classifyRepeatingJobs, + expandJobsToRollingSlots, + getRollingRange, + mapRunStatusesToSlots, +} from "../../lib/public/js/components/cron-tab/cron-calendar-helpers.js"; + +const kMinuteMs = 60 * 1000; +const kHourMs = 60 * kMinuteMs; +const kDayMs = 24 * kHourMs; +// Local-time noon so day boundaries are stable regardless of timezone. +const kNowMs = new Date(2026, 5, 15, 12, 0, 0, 0).getTime(); + +describe("frontend/cron-calendar-helpers (extended)", () => { + it("classifies schedule kinds and malformed cron expressions", () => { + const jobs = [ + { id: "every", schedule: { kind: "every", everyMs: kMinuteMs } }, + { id: "at", schedule: { kind: "at", at: kNowMs } }, + { id: "short-cron", schedule: { kind: "cron", expr: "* *" } }, + { id: "step", schedule: { kind: "cron", expr: "*/15 * * * *" } }, + { id: "zero-step", schedule: { kind: "cron", expr: "*/0 * * * *" } }, + { + id: "dense-list", + schedule: { kind: "cron", expr: "0,15,30 6-13 * * 1-5" }, + }, + { id: "narrow-step", schedule: { kind: "cron", expr: "*/15 8 * * *" } }, + { id: "daily", schedule: { kind: "cron", expr: "0 9 * * *" } }, + ]; + + const { repeatingJobs, scheduledJobs } = classifyRepeatingJobs(jobs); + expect(repeatingJobs.map((job) => job.id)).toEqual([ + "every", + "step", + "dense-list", + ]); + expect(scheduledJobs.map((job) => job.id)).toEqual([ + "at", + "short-cron", + "zero-step", + "narrow-step", + "daily", + ]); + }); + + it("discards out-of-range, inverted, overflowing, and junk cron tokens", () => { + const hugeDigits = "9".repeat(400); + const jobs = [ + // minute 99 out of range, hour range inverted after clamping. + { id: "bad-values", schedule: { kind: "cron", expr: "99 30-10 * * *" } }, + // parseInt overflows to Infinity for absurdly long digit runs. + { + id: "huge-range", + schedule: { kind: "cron", expr: `${hugeDigits}-${hugeDigits} * * * *` }, + }, + { id: "junk-token", schedule: { kind: "cron", expr: "abc 1,2 * * *" } }, + ]; + // Empty minute/hour sets fall back to "match everything", so none of + // these are dense enough to be repeating. + const { repeatingJobs, scheduledJobs } = classifyRepeatingJobs(jobs); + expect(repeatingJobs).toEqual([]); + expect(scheduledJobs).toHaveLength(3); + }); + + it("expands at and cron jobs into rolling slots", () => { + const atInsideMs = kNowMs + kHourMs; + const result = expandJobsToRollingSlots({ + jobs: [ + { id: "every", schedule: { kind: "every", everyMs: kMinuteMs } }, + { id: "at-in", name: "At In", schedule: { kind: "at", at: atInsideMs } }, + { id: "at-out", schedule: { kind: "at", at: kNowMs + 40 * kDayMs } }, + { id: "bad-cron", schedule: { kind: "cron", expr: "* *" } }, + { id: "daily", schedule: { kind: "cron", expr: "30 9 * * *" } }, + ], + nowMs: kNowMs, + }); + + expect(result.range.dayCount).toBe(7); + expect(result.days).toHaveLength(7); + expect(result.days[0].dayKey).toBe("2026-06-12"); + + const atSlots = result.slots.filter((slot) => slot.jobId === "at-in"); + expect(atSlots).toHaveLength(1); + expect(atSlots[0]).toMatchObject({ + key: buildSlotKey({ jobId: "at-in", scheduledAtMs: atInsideMs }), + jobName: "At In", + scheduledAtMs: atInsideMs, + dayKey: "2026-06-15", + hourOfDay: 13, + }); + + const dailySlots = result.slots.filter((slot) => slot.jobId === "daily"); + expect(dailySlots).toHaveLength(7); + expect(new Date(dailySlots[0].scheduledAtMs).getHours()).toBe(9); + expect(new Date(dailySlots[0].scheduledAtMs).getMinutes()).toBe(30); + + expect( + result.slots.some((slot) => + ["every", "at-out", "bad-cron"].includes(slot.jobId), + ), + ).toBe(false); + + // Slots are sorted by time. + const times = result.slots.map((slot) => slot.scheduledAtMs); + expect([...times].sort((left, right) => left - right)).toEqual(times); + }); + + it("expands day-of-week cron fields including the Sunday alias", () => { + // June 14 2026 is a Sunday; day-of-week 7 must match it too. + const result = expandJobsToRollingSlots({ + jobs: [{ id: "sunday", schedule: { kind: "cron", expr: "0 6 * * 7" } }], + nowMs: kNowMs, + }); + expect(result.slots).toHaveLength(1); + expect(new Date(result.slots[0].scheduledAtMs).getDay()).toBe(0); + }); + + it("maps run statuses onto slots with tolerance and consumption", () => { + const baseMs = kNowMs - 6 * kHourMs; + const slots = [ + { key: "job:1", jobId: "job", scheduledAtMs: baseMs }, + { key: "job:2", jobId: "job", scheduledAtMs: baseMs + kMinuteMs }, + { key: "job:3", jobId: "job", scheduledAtMs: baseMs + 2 * kMinuteMs }, + { key: "far:1", jobId: "far", scheduledAtMs: baseMs }, + { key: "none:1", jobId: "none", scheduledAtMs: baseMs }, + { key: "future:1", jobId: "job", scheduledAtMs: kNowMs + kHourMs }, + ]; + const statusBySlotKey = mapRunStatusesToSlots({ + slots, + bulkRunsByJobId: { + job: { + entries: [ + { ts: baseMs + 10 * kMinuteMs, status: "ERROR" }, + { ts: baseMs, status: "ok" }, + { ts: 0, status: "ok" }, + { ts: baseMs + 20 * kMinuteMs, status: "bogus-status" }, + ], + }, + far: { + entries: [{ ts: baseMs + 3 * kHourMs, status: "skipped" }], + }, + empty: { entries: "not-an-array" }, + }, + nowMs: kNowMs, + }); + + expect(statusBySlotKey).toEqual({ + "job:1": "ok", + "job:2": "error", + }); + // job:3 finds every entry consumed, far:1 only has an out-of-tolerance + // run, none:1 has no runs, and future:1 is skipped entirely. + expect(statusBySlotKey["job:3"]).toBeUndefined(); + expect(statusBySlotKey["far:1"]).toBeUndefined(); + expect(statusBySlotKey["none:1"]).toBeUndefined(); + expect(statusBySlotKey["future:1"]).toBeUndefined(); + }); + + it("marks every job unknown or disabled when no usage exists", () => { + expect( + buildTokenTierByJobId({ + jobs: [{ id: "a" }, { id: "b", enabled: false }], + usageByJobId: {}, + }), + ).toEqual({ a: "unknown", b: "disabled" }); + expect(buildTokenTierByJobId()).toEqual({}); + }); + + it("falls back to defaults for junk rolling range inputs", () => { + const range = getRollingRange({ + nowMs: "not-a-number", + pastDays: "junk", + futureDays: "junk", + }); + expect(range.dayCount).toBe(7); + expect(range.rangeEndMs).toBeGreaterThan(range.rangeStartMs); + }); +}); diff --git a/tests/frontend/cron-helpers.test.js b/tests/frontend/cron-helpers.test.js index c00c4def..c579d87c 100644 --- a/tests/frontend/cron-helpers.test.js +++ b/tests/frontend/cron-helpers.test.js @@ -171,5 +171,362 @@ describe("frontend/cron-helpers", () => { expect(formatRelativeCompact(nowMs - 10 * 60 * 60 * 1000, nowMs)).toBe("10h"); expect(formatRelativeCompact(nowMs - 10 * 24 * 60 * 60 * 1000, nowMs)).toBe("10d"); expect(formatRelativeCompact(nowMs - 30 * 24 * 60 * 60 * 1000, nowMs)).toBe("1mo"); + expect(formatRelativeCompact(0, nowMs)).toBe("—"); + expect(formatRelativeCompact("junk", nowMs)).toBe("—"); + expect(formatRelativeCompact(nowMs + 90 * 1000, nowMs)).toBe("2m"); + }); + + it("formats relative timestamps in both directions", async () => { + const { formatRelativeMs } = await loadCronHelpers(); + const nowMs = Date.now(); + expect(formatRelativeMs(0, nowMs)).toBe("—"); + expect(formatRelativeMs(Number.NaN, nowMs)).toBe("—"); + expect(formatRelativeMs(nowMs + 10 * 1000, nowMs)).toBe("in <1m"); + expect(formatRelativeMs(nowMs - 10 * 1000, nowMs)).toBe("just now"); + expect(formatRelativeMs(nowMs + 5 * 60 * 1000, nowMs)).toBe("in 5m"); + expect(formatRelativeMs(nowMs - 5 * 60 * 1000, nowMs)).toBe("5m ago"); + expect(formatRelativeMs(nowMs + 3 * 60 * 60 * 1000, nowMs)).toBe("in 3h"); + expect(formatRelativeMs(nowMs - 3 * 60 * 60 * 1000, nowMs)).toBe("3h ago"); + expect(formatRelativeMs(nowMs + 2 * 24 * 60 * 60 * 1000, nowMs)).toBe("in 2d"); + expect(formatRelativeMs(nowMs - 2 * 24 * 60 * 60 * 1000, nowMs)).toBe("2d ago"); + }); + + it("formats overdue next runs at hour and day granularity", async () => { + const { formatNextRunRelativeMs } = await loadCronHelpers(); + const nowMs = Date.now(); + expect(formatNextRunRelativeMs(0, nowMs)).toBe("—"); + expect(formatNextRunRelativeMs(-5, nowMs)).toBe("—"); + expect(formatNextRunRelativeMs(nowMs - 3 * 60 * 60 * 1000, nowMs)).toBe("overdue by 3h"); + expect(formatNextRunRelativeMs(nowMs - 4 * 24 * 60 * 60 * 1000, nowMs)).toBe( + "overdue by 4d", + ); + }); + + it("humanizes interval, daily, and edge-case cron expressions", async () => { + const { formatCronScheduleLabel } = await loadCronHelpers(); + expect(formatCronScheduleLabel({ kind: "cron", expr: "*/5 * * * *" })).toBe("Every 5m"); + expect(formatCronScheduleLabel({ kind: "cron", expr: "0 */2 * * *" })).toBe("Every 2h"); + expect(formatCronScheduleLabel({ kind: "cron", expr: "30 18 * * *" })).toBe( + "Daily at 6:30pm", + ); + expect(formatCronScheduleLabel({ kind: "cron", expr: "0 0 * * *" })).toBe( + "Daily at 12:00am", + ); + expect(formatCronScheduleLabel({ kind: "cron", expr: "15 9 * * 0,6" })).toBe( + "Every Sun, Sat at 9:15am", + ); + // Non-humanizable expressions fall back to the raw expression. + expect(formatCronScheduleLabel({ kind: "cron", expr: "0 8 15 6 *" })).toBe("0 8 15 6 *"); + expect(formatCronScheduleLabel({ kind: "cron", expr: "a b * * *" })).toBe("a b * * *"); + expect(formatCronScheduleLabel({ kind: "cron", expr: "0 8" })).toBe("0 8"); + // Day-of-month out of the 1-31 range is not humanized. + expect(formatCronScheduleLabel({ kind: "cron", expr: "0 4 0 * *" })).toBe("0 4 0 * *"); + // Minute-step with a weekday hour range but broken minute field. + expect(formatCronScheduleLabel({ kind: "cron", expr: "*/x 6-13 * * 1-5" })).toBe( + "*/x 6-13 * * 1-5", + ); + }); + + it("covers remaining schedule label branches", async () => { + const { formatCronScheduleLabel } = await loadCronHelpers(); + expect(formatCronScheduleLabel({ kind: "every" })).toBe("Every interval"); + expect(formatCronScheduleLabel({ kind: "cron" })).toBe("Cron"); + expect(formatCronScheduleLabel({ kind: "cron", expr: " " })).toBe("Cron"); + expect(formatCronScheduleLabel({})).toBe("Unknown schedule"); + expect(formatCronScheduleLabel()).toBe("Unknown schedule"); + + // includeTimeZone always appends the schedule tz. + expect( + formatCronScheduleLabel( + { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + { includeTimeZone: true }, + ), + ).toBe("Daily at 9:00am (UTC)"); + // Non-humanizable expr with tz appended. + expect( + formatCronScheduleLabel( + { kind: "cron", expr: "0 8 15 6 *", tz: "UTC" }, + { includeTimeZone: true }, + ), + ).toBe("0 8 15 6 * (UTC)"); + // No schedule tz -> nothing to append even when requested. + expect( + formatCronScheduleLabel( + { kind: "cron", expr: "0 9 * * *" }, + { includeTimeZone: true, includeTimeZoneWhenDifferent: true }, + ), + ).toBe("Daily at 9:00am"); + + // Fallback path (no kind) via cronExpr/timezone aliases. + expect( + formatCronScheduleLabel( + { cronExpr: "0 9 * * *", timezone: "UTC" }, + { includeTimeZone: true }, + ), + ).toBe("Daily at 9:00am (UTC)"); + expect( + formatCronScheduleLabel( + { expr: "0 8 15 6 *", timezone: "UTC" }, + { includeTimeZone: true }, + ), + ).toBe("0 8 15 6 * (UTC)"); + expect(formatCronScheduleLabel({ cron: "0 8 15 6 *" })).toBe("0 8 15 6 *"); + }); + + it("resolves the client time zone from Intl when not provided", async () => { + const { formatCronScheduleLabel } = await loadCronHelpers(); + const schedule = { kind: "cron", expr: "0 9 * * *", tz: "Etc/GMT+8" }; + const options = { includeTimeZoneWhenDifferent: true }; + try { + // Client tz differs from schedule tz -> append. + vi.stubGlobal("Intl", { + DateTimeFormat: () => ({ + resolvedOptions: () => ({ timeZone: "America/New_York" }), + }), + }); + expect(formatCronScheduleLabel(schedule, options)).toBe( + "Daily at 9:00am (Etc/GMT+8)", + ); + + // Client tz matches schedule tz -> no suffix. + vi.stubGlobal("Intl", { + DateTimeFormat: () => ({ + resolvedOptions: () => ({ timeZone: "etc/gmt+8" }), + }), + }); + expect(formatCronScheduleLabel(schedule, options)).toBe("Daily at 9:00am"); + + // Intl blows up -> unknown client tz -> append defensively. + vi.stubGlobal("Intl", { + DateTimeFormat: () => { + throw new Error("no intl"); + }, + }); + expect(formatCronScheduleLabel(schedule, options)).toBe( + "Daily at 9:00am (Etc/GMT+8)", + ); + + // Intl reports no time zone -> append defensively. + vi.stubGlobal("Intl", { + DateTimeFormat: () => ({ resolvedOptions: () => ({}) }), + }); + expect(formatCronScheduleLabel(schedule, options)).toBe( + "Daily at 9:00am (Etc/GMT+8)", + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("derives job health and health class names", async () => { + const { getCronJobHealth, getCronJobHealthClassName } = await loadCronHelpers(); + expect(getCronJobHealth({ enabled: false })).toBe("disabled"); + expect(getCronJobHealth({ state: { runningAtMs: 123 } })).toBe("running"); + expect(getCronJobHealth({ state: { lastStatus: "ERROR" } })).toBe("error"); + expect(getCronJobHealth({ state: { lastRunStatus: "ok" } })).toBe("ok"); + expect(getCronJobHealth({ state: {} })).toBe("unknown"); + expect(getCronJobHealth()).toBe("unknown"); + + expect(getCronJobHealthClassName("ok")).toBe("bg-green-500"); + expect(getCronJobHealthClassName("error")).toBe("bg-red-500"); + expect(getCronJobHealthClassName("running")).toBe("bg-yellow-400"); + expect(getCronJobHealthClassName("disabled")).toBe("bg-gray-500"); + expect(getCronJobHealthClassName()).toBe("bg-gray-500"); + }); + + it("formats token counts and costs", async () => { + const { formatTokenCount, formatCost } = await loadCronHelpers(); + expect(formatTokenCount(1234)).toBe("1,234"); + expect(formatTokenCount()).toBe("0"); + expect(formatCost(1.5)).toContain("1.5"); + expect(formatCost()).toBeTruthy(); + }); + + it("computes run token totals from components and fallbacks", async () => { + const { getCronRunTotalTokens } = await loadCronHelpers(); + expect( + getCronRunTotalTokens({ + usage: { + input_tokens: 10, + outputTokens: 20, + cache_read_tokens: 30, + cacheWriteTokens: 40, + inputTokens: -1, + output_tokens: "junk", + }, + }), + ).toBe(100); + expect(getCronRunTotalTokens({ usage: { total_tokens: 500 } })).toBe(500); + expect(getCronRunTotalTokens({ usage: { totalTokens: 400 } })).toBe(400); + expect(getCronRunTotalTokens({ total_tokens: 300 })).toBe(300); + expect(getCronRunTotalTokens({ totalTokens: 200 })).toBe(200); + expect(getCronRunTotalTokens({ usage: { total_tokens: -5 } })).toBe(0); + expect(getCronRunTotalTokens({})).toBe(0); + expect(getCronRunTotalTokens()).toBe(0); + }); + + it("reads estimated run costs from candidate fields", async () => { + const { getCronRunEstimatedCost } = await loadCronHelpers(); + expect(getCronRunEstimatedCost({ estimatedCost: 0.25 })).toBe(0.25); + expect(getCronRunEstimatedCost({ estimated_cost: 0.5 })).toBe(0.5); + expect(getCronRunEstimatedCost({ usage: { estimatedCost: 0.75 } })).toBe(0.75); + expect(getCronRunEstimatedCost({ usage: { totalCost: 1.25 } })).toBe(1.25); + expect(getCronRunEstimatedCost({ usage: { cost: 0.05 } })).toBe(0.05); + expect(getCronRunEstimatedCost({ estimatedCost: "junk", usage: { cost: -1 } })).toBeNull(); + expect(getCronRunEstimatedCost({})).toBeNull(); + expect(getCronRunEstimatedCost()).toBeNull(); + }); + + it("covers heartbeat suppression and warning edge cases", async () => { + const { buildCronOptimizationWarnings } = await loadCronHelpers(); + + // Latest bulk run is picked by ts; HEARTBEAT_OK in nested summaries suppresses. + const suppressedByLatest = buildCronOptimizationWarnings( + [ + { + id: "job-nested", + name: "Nested Heartbeat", + delivery: { mode: "announce" }, + payload: { kind: "agentTurn", message: "noop" }, + state: { lastDelivered: false, lastDeliveryStatus: "not-delivered" }, + }, + ], + { + "job-nested": { + entries: [ + { ts: 100, summary: "older failure" }, + { ts: 300, result: { summary: "HEARTBEAT_OK nested" } }, + { ts: 200, summary: "middle" }, + ], + }, + }, + ); + expect(suppressedByLatest).toHaveLength(0); + + // payload.summary candidate also suppresses. + const suppressedByPayload = buildCronOptimizationWarnings( + [ + { + id: "job-payload", + name: "Payload Heartbeat", + delivery: { mode: "announce" }, + payload: { kind: "agentTurn", message: "noop" }, + state: { lastDelivered: false, lastDeliveryStatus: "not-delivered" }, + }, + ], + { + "job-payload": { + entries: [{ ts: 100, payload: { summary: "heartbeat_ok lower case" } }], + }, + }, + ); + expect(suppressedByPayload).toHaveLength(0); + + // Latest run status "ok" from bulk entries suppresses the warning too. + const suppressedByStatus = buildCronOptimizationWarnings( + [ + { + id: "job-ok", + name: "Latest Ok", + delivery: { mode: "announce" }, + payload: { kind: "agentTurn", message: "noop" }, + state: { lastDelivered: false, lastDeliveryStatus: "not-delivered" }, + }, + ], + { "job-ok": { entries: [{ ts: 100, status: "OK" }] } }, + ); + expect(suppressedByStatus).toHaveLength(0); + + // Circular job state makes JSON.stringify throw; warning still fires. + const circularState = { + lastDelivered: false, + lastDeliveryStatus: "not-delivered", + }; + circularState.self = circularState; + const circularWarnings = buildCronOptimizationWarnings( + [ + { + id: "job-circular", + delivery: { mode: "announce" }, + payload: { kind: "agentTurn", message: "noop" }, + state: circularState, + }, + ], + {}, + ); + expect(circularWarnings).toHaveLength(1); + expect(circularWarnings[0].title).toContain("job-circular"); + + // The delivery-mismatch warning was removed upstream (delivery.mode=none + // with a message-tool prompt is valid); such jobs no longer warn. + const mismatch = buildCronOptimizationWarnings( + [ + { + id: "job-mismatch", + name: "Mismatch", + delivery: { mode: "none" }, + payload: { kind: "agentTurn", message: "Please use the MESSAGE TOOL here" }, + state: {}, + }, + ], + {}, + ); + expect(mismatch).toHaveLength(0); + + // A job without an id still evaluates safely. + const anonymous = buildCronOptimizationWarnings( + [ + { + name: "Anonymous", + delivery: { mode: "announce" }, + payload: {}, + state: { + lastDelivered: false, + lastDeliveryStatus: "not-delivered", + consecutiveErrors: 2, + }, + }, + ], + {}, + ); + expect(anonymous).toHaveLength(2); + + // Warnings are capped at eight. + const manyJobs = Array.from({ length: 12 }, (_, index) => ({ + id: `job-${index}`, + name: `Job ${index}`, + delivery: { mode: "announce" }, + payload: { kind: "agentTurn", message: "noop" }, + state: { consecutiveErrors: 5 }, + })); + expect(buildCronOptimizationWarnings(manyJobs, {})).toHaveLength(8); + expect(buildCronOptimizationWarnings()).toEqual([]); + }); + + it("finds the next scheduled run across enabled jobs", async () => { + const { getNextScheduledRunAcrossJobs, kAllCronJobsRouteKey } = await loadCronHelpers(); + expect(kAllCronJobsRouteKey).toBe("__all__"); + expect( + getNextScheduledRunAcrossJobs([ + { enabled: true, state: { nextRunAtMs: 2000 } }, + { enabled: false, state: { nextRunAtMs: 100 } }, + { enabled: true, state: { nextRunAtMs: 1000 } }, + { enabled: true, state: {} }, + { enabled: true, state: { nextRunAtMs: "junk" } }, + ]), + ).toBe(1000); + expect(getNextScheduledRunAcrossJobs([])).toBeNull(); + expect(getNextScheduledRunAcrossJobs()).toBeNull(); + expect( + getNextScheduledRunAcrossJobs([{ enabled: false, state: { nextRunAtMs: 5 } }]), + ).toBeNull(); + }); + + it("handles non-object payloads when reading prompts", async () => { + const { readCronJobPrompt } = await loadCronHelpers(); + expect(readCronJobPrompt({ payload: null })).toBe(""); + expect(readCronJobPrompt()).toBe(""); + expect(readCronJobPrompt({ payload: { kind: "systemEvent", text: 42 } })).toBe(""); + expect(readCronJobPrompt({ payload: { kind: "agentTurn", message: 42 } })).toBe(""); }); }); diff --git a/tests/frontend/doctor-helpers-more.test.js b/tests/frontend/doctor-helpers-more.test.js new file mode 100644 index 00000000..959e7c2c --- /dev/null +++ b/tests/frontend/doctor-helpers-more.test.js @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { + buildDoctorRunMarkers, + buildDoctorStatusFilterOptions, + formatDoctorCategory, + formatDoctorCharCount, + getDoctorBootstrapWarningTitle, + getDoctorCategoryTone, + getDoctorChangeLabel, + getDoctorPriorityTone, + getDoctorRunPillDetail, + getDoctorStatusTone, + getDoctorWarningMessage, + shouldShowDoctorWarning, +} from "../../lib/public/js/components/doctor/helpers.js"; + +const bootstrapStatus = ({ truncated = [], nearLimit = [] } = {}) => ({ + bootstrapContext: { + activeTruncatedFiles: truncated, + activeNearLimitFiles: nearLimit, + }, +}); + +describe("frontend/doctor helpers (extended)", () => { + it("maps priorities to tones", () => { + expect(getDoctorPriorityTone("P0")).toBe("danger"); + expect(getDoctorPriorityTone(" p1 ")).toBe("warning"); + expect(getDoctorPriorityTone("P2")).toBe("neutral"); + expect(getDoctorPriorityTone()).toBe("neutral"); + }); + + it("maps statuses to tones", () => { + expect(getDoctorStatusTone("Fixed")).toBe("success"); + expect(getDoctorStatusTone("working")).toBe("info"); + expect(getDoctorStatusTone("dismissed")).toBe("neutral"); + expect(getDoctorStatusTone("open")).toBe("warning"); + expect(getDoctorStatusTone()).toBe("warning"); + }); + + it("maps categories to tones with a default", () => { + expect(getDoctorCategoryTone("mixed-concerns")).toBe("cyan"); + expect(getDoctorCategoryTone("something else")).toBe("info"); + expect(getDoctorCategoryTone()).toBe("info"); + }); + + it("formats empty categories as Workspace", () => { + expect(formatDoctorCategory("")).toBe("Workspace"); + expect(formatDoctorCategory(null)).toBe("Workspace"); + }); + + it("suppresses the warning for missing or in-progress statuses", () => { + expect(shouldShowDoctorWarning(null)).toBe(false); + expect( + shouldShowDoctorWarning({ + runInProgress: true, + needsInitialRun: false, + stale: true, + changeSummary: { hasMeaningfulChanges: true }, + }), + ).toBe(false); + }); + + it("builds warning messages for all change states", () => { + expect(getDoctorWarningMessage(null)).toBe(""); + expect( + getDoctorWarningMessage({ changeSummary: { changedFilesCount: 0 } }), + ).toBe("Doctor has not been run in the last week."); + expect(getDoctorWarningMessage({})).toBe( + "Doctor has not been run in the last week.", + ); + expect( + getDoctorWarningMessage({ changeSummary: { changedFilesCount: 1 } }), + ).toBe( + "Drift Doctor has not been run in the last week and 1 file changed since the last review.", + ); + }); + + it("formats char counts", () => { + expect(formatDoctorCharCount(1234)).toBe("1,234 chars"); + expect(formatDoctorCharCount()).toBe("0 chars"); + }); + + it("builds bootstrap warning titles for each combination", () => { + expect(getDoctorBootstrapWarningTitle(null)).toBe(""); + expect(getDoctorBootstrapWarningTitle(bootstrapStatus())).toBe(""); + + const truncatedFile = { path: "notes.md", rawChars: 10, injectedChars: 4 }; + const nearLimitFile = { path: "todo.md", rawChars: 9 }; + const managedFile = { path: "hooks/bootstrap/managed.md", rawChars: 9 }; + + expect( + getDoctorBootstrapWarningTitle( + bootstrapStatus({ + truncated: [truncatedFile, managedFile], + nearLimit: [nearLimitFile, managedFile], + }), + ), + ).toBe("Some of your main files are being truncated or nearing the limit:"); + expect( + getDoctorBootstrapWarningTitle( + bootstrapStatus({ nearLimit: [nearLimitFile] }), + ), + ).toBe("One of your main files is nearing the limit:"); + expect( + getDoctorBootstrapWarningTitle( + bootstrapStatus({ nearLimit: [nearLimitFile, { path: "b.md" }] }), + ), + ).toBe("Some of your main files are nearing the limit:"); + expect( + getDoctorBootstrapWarningTitle( + bootstrapStatus({ truncated: [truncatedFile] }), + ), + ).toBe("One of your main files is being truncated:"); + expect( + getDoctorBootstrapWarningTitle( + bootstrapStatus({ truncated: [truncatedFile, { path: "b.md" }] }), + ), + ).toBe("Some of your main files are being truncated:"); + }); + + it("labels change summaries with correct pluralization", () => { + expect(getDoctorChangeLabel({ changedFilesCount: 1 })).toBe( + "1 change since last run", + ); + expect(getDoctorChangeLabel({ changedFilesCount: 2 })).toBe( + "2 changes since last run", + ); + expect(getDoctorChangeLabel(null)).toBe("No changes since last run"); + }); + + it("describes run pills for every run shape", () => { + expect(getDoctorRunPillDetail(null)).toBe(""); + expect(getDoctorRunPillDetail("not-an-object")).toBe(""); + expect(getDoctorRunPillDetail({ status: "running" })).toBe("Running"); + expect(getDoctorRunPillDetail({ status: "completed", cardCount: 1 })).toBe( + "1 finding", + ); + expect(getDoctorRunPillDetail({ status: "completed", cardCount: 3 })).toBe( + "3 findings", + ); + }); + + it("builds run markers for failures and P2-only runs", () => { + expect(buildDoctorRunMarkers(null)).toEqual([]); + expect(buildDoctorRunMarkers({ status: "failed" })).toEqual([ + { tone: "neutral", count: 0, label: "Failed" }, + ]); + expect( + buildDoctorRunMarkers({ + status: "completed", + cardCount: 2, + priorityCounts: { P0: 0, P1: 0, P2: 2 }, + }), + ).toEqual([{ tone: "neutral", count: 0, label: "P2" }]); + }); + + it("lists the status filter options", () => { + expect(buildDoctorStatusFilterOptions().map((option) => option.value)).toEqual([ + "open", + "working", + "dismissed", + "fixed", + ]); + }); +}); diff --git a/tests/frontend/file-viewer-utils-more.test.js b/tests/frontend/file-viewer-utils-more.test.js new file mode 100644 index 00000000..0fa543f9 --- /dev/null +++ b/tests/frontend/file-viewer-utils-more.test.js @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + clampSelectionIndex, + parsePathSegments, +} from "../../lib/public/js/components/file-viewer/utils.js"; + +describe("frontend/file-viewer-utils (extended)", () => { + it("parses path segments, trimming and dropping empties", () => { + expect(parsePathSegments("a//b/ c /")).toEqual(["a", "b", "c"]); + expect(parsePathSegments("")).toEqual([]); + expect(parsePathSegments(null)).toEqual([]); + }); + + it("clamps selection indexes to the valid range", () => { + expect(clampSelectionIndex("3", 5)).toBe(3); + expect(clampSelectionIndex(9, 5)).toBe(5); + expect(clampSelectionIndex(-2, 5)).toBe(0); + expect(clampSelectionIndex("not-a-number", 5)).toBe(0); + expect(clampSelectionIndex(undefined, 5)).toBe(0); + }); +}); diff --git a/tests/frontend/format.test.js b/tests/frontend/format.test.js new file mode 100644 index 00000000..968b2492 --- /dev/null +++ b/tests/frontend/format.test.js @@ -0,0 +1,148 @@ +const loadFormatModule = async () => import("../../lib/public/js/lib/format.js"); + +class ThrowingDate extends Date { + getTime() { + throw new Error("boom"); + } +} + +describe("frontend/format", () => { + it("formatInteger formats with grouping and defaults to zero", async () => { + const { formatInteger } = await loadFormatModule(); + + expect(formatInteger(1234567)).toBe("1,234,567"); + expect(formatInteger(undefined)).toBe("0"); + }); + + it("formatCompactNumber handles small, large, and non-finite values", async () => { + const { formatCompactNumber } = await loadFormatModule(); + + expect(formatCompactNumber(999)).toBe("999"); + expect(formatCompactNumber(-999)).toBe("-999"); + expect(formatCompactNumber(1500)).toBe("1.5K"); + expect(formatCompactNumber(-2500000)).toBe("-2.5M"); + expect(formatCompactNumber(Infinity)).toBe("0"); + expect(formatCompactNumber(undefined)).toBe("0"); + }); + + it("formatBytes scales through units with adaptive precision", async () => { + const { formatBytes } = await loadFormatModule(); + + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(-5)).toBe("0 B"); + expect(formatBytes(Infinity)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2.00 KB"); + expect(formatBytes(15 * 1024)).toBe("15.0 KB"); + expect(formatBytes(200 * 1024)).toBe("200 KB"); + expect(formatBytes(5 * 1024 ** 3)).toBe("5.00 GB"); + expect(formatBytes(2000 * 1024 ** 4)).toBe("2000 TB"); + }); + + it("formatUsd formats currency values", async () => { + const { formatUsd } = await loadFormatModule(); + + expect(formatUsd(1234.5)).toBe("$1,234.50"); + expect(formatUsd(0.005)).toBe("$0.005"); + expect(formatUsd(undefined)).toBe("$0.00"); + }); + + it("formatLocaleDateTime converts supported value shapes", async () => { + const { formatLocaleDateTime } = await loadFormatModule(); + const date = new Date(2026, 0, 2, 3, 4, 5); + + expect(formatLocaleDateTime(date)).toBe(date.toLocaleString()); + expect(formatLocaleDateTime(1700000000, { valueIsUnixSeconds: true })).toBe( + new Date(1700000000 * 1000).toLocaleString(), + ); + expect(formatLocaleDateTime(date.getTime(), { valueIsEpochMs: true })).toBe( + date.toLocaleString(), + ); + expect(formatLocaleDateTime("2026-01-02T03:04:05")).toBe( + new Date("2026-01-02T03:04:05").toLocaleString(), + ); + }); + + it("formatLocaleDateTime falls back for empty, invalid, and throwing values", async () => { + const { formatLocaleDateTime } = await loadFormatModule(); + + expect(formatLocaleDateTime(null)).toBe("—"); + expect(formatLocaleDateTime("")).toBe("—"); + expect(formatLocaleDateTime("not-a-date")).toBe("—"); + expect(formatLocaleDateTime("not-a-date", { fallback: "n/a" })).toBe("n/a"); + expect(formatLocaleDateTime(new ThrowingDate())).toBe("—"); + }); + + it("formatLocaleDateTimeWithTodayTime prints time only for today", async () => { + const { formatLocaleDateTimeWithTodayTime } = await loadFormatModule(); + const now = new Date(); + const twoDaysAgo = new Date(Date.now() - 2 * 86400000); + + expect(formatLocaleDateTimeWithTodayTime(now)).toBe(now.toLocaleTimeString()); + expect(formatLocaleDateTimeWithTodayTime(twoDaysAgo)).toBe( + twoDaysAgo.toLocaleString(), + ); + expect(formatLocaleDateTimeWithTodayTime(null)).toBe("—"); + expect(formatLocaleDateTimeWithTodayTime("nope", { fallback: "x" })).toBe("x"); + expect(formatLocaleDateTimeWithTodayTime(new ThrowingDate())).toBe("—"); + expect( + formatLocaleDateTimeWithTodayTime(1700000000, { valueIsUnixSeconds: true }), + ).toBe(new Date(1700000000 * 1000).toLocaleString()); + }); + + it("formatDurationCompactMs formats durations", async () => { + const { formatDurationCompactMs } = await loadFormatModule(); + + expect(formatDurationCompactMs(0)).toBe("0s"); + expect(formatDurationCompactMs(-10)).toBe("0s"); + expect(formatDurationCompactMs(Infinity)).toBe("0s"); + expect(formatDurationCompactMs(500)).toBe("500ms"); + expect(formatDurationCompactMs(5000)).toBe("5s"); + expect(formatDurationCompactMs(59_400)).toBe("59s"); + expect(formatDurationCompactMs(60_000)).toBe("1m 0s"); + expect(formatDurationCompactMs(125_000)).toBe("2m 5s"); + }); + + it("formatChartBucketLabel formats day keys per range", async () => { + const { formatChartBucketLabel } = await loadFormatModule(); + const date = new Date(2026, 6, 1); + + expect( + formatChartBucketLabel("2026-07-01", { range: "7d", valueType: "day-key" }), + ).toBe( + date.toLocaleDateString([], { + weekday: "short", + month: "numeric", + day: "numeric", + }), + ); + expect( + formatChartBucketLabel("2026-07-01", { range: "30d", valueType: "day-key" }), + ).toBe(date.toLocaleDateString([], { month: "numeric", day: "numeric" })); + expect(formatChartBucketLabel("not-a-day", { valueType: "day-key" })).toBe( + "not-a-day", + ); + expect(formatChartBucketLabel(null, { valueType: "day-key" })).toBe(""); + }); + + it("formatChartBucketLabel formats epoch and date values", async () => { + const { formatChartBucketLabel } = await loadFormatModule(); + const date = new Date(2026, 6, 1, 13, 30); + + expect(formatChartBucketLabel(date.getTime(), { range: "24h" })).toBe( + date.toLocaleTimeString([], { hour: "numeric" }), + ); + expect(formatChartBucketLabel(date.getTime())).toBe( + date.toLocaleDateString([], { + weekday: "short", + month: "numeric", + day: "numeric", + }), + ); + expect(formatChartBucketLabel("abc", {})).toBe("abc"); + expect(formatChartBucketLabel(date, { range: "30d", valueType: "date" })).toBe( + date.toLocaleDateString([], { month: "numeric", day: "numeric" }), + ); + expect(formatChartBucketLabel("nope", { valueType: "date" })).toBe("nope"); + }); +}); diff --git a/tests/frontend/models-tab-model-picker-component.test.js b/tests/frontend/models-tab-model-picker-component.test.js new file mode 100644 index 00000000..d169f901 --- /dev/null +++ b/tests/frontend/models-tab-model-picker-component.test.js @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A minimal hook harness so the SearchableModelPicker component function can +// be invoked directly (the repo has no DOM/preact render harness). State is +// stored per hook-call index and persists between simulated renders. +vi.mock("preact/hooks", () => { + const harness = { + slots: [], + cursor: 0, + effects: [], + }; + harness.beginRender = () => { + harness.cursor = 0; + harness.effects = []; + }; + harness.reset = () => { + harness.slots = []; + harness.cursor = 0; + harness.effects = []; + }; + const useState = (initialValue) => { + const index = harness.cursor++; + if (!(index in harness.slots)) { + harness.slots[index] = + typeof initialValue === "function" ? initialValue() : initialValue; + } + const setState = (next) => { + harness.slots[index] = + typeof next === "function" ? next(harness.slots[index]) : next; + }; + return [harness.slots[index], setState]; + }; + const useRef = (initialValue = null) => { + const index = harness.cursor++; + if (!(index in harness.slots)) { + harness.slots[index] = { current: initialValue }; + } + return harness.slots[index]; + }; + const useMemo = (factory) => factory(); + const useEffect = (effect) => { + harness.effects.push(effect); + }; + return { useState, useRef, useMemo, useEffect, __harness: harness }; +}); + +import { SearchableModelPicker } from "../../lib/public/js/components/models-tab/model-picker.js"; +import * as preactHooks from "preact/hooks"; + +const harness = preactHooks.__harness; +// Hook call order in the component: useState(query)=0, useState(open)=1, useRef=2. +const kQuerySlot = 0; +const kOpenSlot = 1; +const kRootRefSlot = 2; + +const renderPicker = (props = {}) => { + harness.beginRender(); + return SearchableModelPicker(props); +}; + +const collectVnodes = (node, out = []) => { + if (node == null || typeof node !== "object") return out; + if (Array.isArray(node)) { + for (const child of node) collectVnodes(child, out); + return out; + } + out.push(node); + if (node.props) collectVnodes(node.props.children, out); + return out; +}; + +const findAllByType = (tree, type) => + collectVnodes(tree).filter((vnode) => vnode.type === type); + +const collectText = (node, out = []) => { + if (typeof node === "string" || typeof node === "number") { + out.push(String(node)); + return out; + } + if (Array.isArray(node)) { + for (const child of node) collectText(child, out); + return out; + } + if (node && typeof node === "object" && node.props) { + collectText(node.props.children, out); + } + return out; +}; + +const treeText = (tree) => collectText(tree).join(" "); + +const kOptions = [ + { key: "anthropic/claude-opus-4-6", label: "Opus 4.6" }, + { key: "anthropic/claude-sonnet-4-6", label: "Sonnet 4.6" }, + { key: "openai/gpt-5.5", label: "gpt-5.5" }, +]; +const kPopular = [{ key: "openai/gpt-5.5" }, { key: "missing/model" }]; + +describe("frontend/models-tab SearchableModelPicker", () => { + beforeEach(() => { + harness.reset(); + }); + + afterEach(() => { + delete global.document; + }); + + it("registers and cleans up the outside-pointer listener", () => { + const listeners = {}; + global.document = { + addEventListener: vi.fn((name, handler) => { + listeners[name] = handler; + }), + removeEventListener: vi.fn(), + }; + renderPicker({}); + expect(harness.effects).toHaveLength(1); + const cleanup = harness.effects[0](); + const handler = listeners.mousedown; + expect(typeof handler).toBe("function"); + + // rootRef.current is null -> pointer counts as outside -> closes. + harness.slots[kOpenSlot] = true; + handler({ target: {} }); + expect(harness.slots[kOpenSlot]).toBe(false); + + // Pointer inside the root keeps the dropdown open. + harness.slots[kRootRefSlot].current = { contains: () => true }; + harness.slots[kOpenSlot] = true; + handler({ target: {} }); + expect(harness.slots[kOpenSlot]).toBe(true); + + cleanup(); + expect(global.document.removeEventListener).toHaveBeenCalledWith( + "mousedown", + handler, + ); + }); + + it("opens on focus, tracks input, and ignores Enter with no options", () => { + const tree = renderPicker({}); + const input = findAllByType(tree, "input")[0]; + expect(input).toBeTruthy(); + + input.props.onFocus(); + expect(harness.slots[kOpenSlot]).toBe(true); + + input.props.onInput({ target: { value: "sonnet" } }); + expect(harness.slots[kQuerySlot]).toBe("sonnet"); + expect(harness.slots[kOpenSlot]).toBe(true); + + // No visible options: Enter is a no-op, other keys fall through. + harness.slots[kQuerySlot] = ""; + input.props.onKeyDown({ key: "Enter" }); + input.props.onKeyDown({ key: "ArrowDown" }); + expect(harness.slots[kOpenSlot]).toBe(true); + }); + + it("groups open options with a popular section and selects on click", () => { + const onSelect = vi.fn(); + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: kOptions, + popularModels: kPopular, + onSelect, + }); + + const text = treeText(tree); + expect(text).toContain("POPULAR"); + expect(text).toContain("ANTHROPIC"); + expect(text).toContain("OPENAI"); + + const buttons = findAllByType(tree, "button"); + // 1 visible popular model (missing/model filtered out) + 3 options. + expect(buttons).toHaveLength(4); + + const preventDefault = vi.fn(); + buttons[0].props.onMouseDown({ preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(1); + + buttons[0].props.onClick(); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-5.5"); + expect(harness.slots[kQuerySlot]).toBe(""); + expect(harness.slots[kOpenSlot]).toBe(false); + }); + + it("selects the first visible option on Enter and closes on Escape", () => { + const onSelect = vi.fn(); + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ options: kOptions, onSelect }); + const input = findAllByType(tree, "input")[0]; + + const preventDefault = vi.fn(); + input.props.onKeyDown({ key: "Enter", preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("anthropic/claude-opus-4-6"); + expect(harness.slots[kOpenSlot]).toBe(false); + + harness.slots[kOpenSlot] = true; + input.props.onKeyDown({ key: "Escape" }); + expect(harness.slots[kOpenSlot]).toBe(false); + }); + + it("filters options by query and hides the popular section", () => { + harness.slots[kQuerySlot] = "sonnet"; + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: kOptions, + popularModels: kPopular, + }); + + const text = treeText(tree); + expect(text).not.toContain("POPULAR"); + expect(text).toContain("ANTHROPIC"); + + const buttons = findAllByType(tree, "button"); + expect(buttons).toHaveLength(1); + // Exercises the default onSelect noop. + buttons[0].props.onClick(); + expect(harness.slots[kOpenSlot]).toBe(false); + }); + + it("explains when the only match is already configured", () => { + harness.slots[kQuerySlot] = "gpt"; + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: [kOptions[0], kOptions[1]], + configuredOptions: [{ key: "openai/gpt-5.5", label: "gpt-5.5" }], + }); + + const text = treeText(tree); + expect(text).toContain("Already added above:"); + expect(text).toContain("GPT-5.5"); + expect(findAllByType(tree, "button")).toHaveLength(0); + }); + + it("summarizes multiple already-configured matches", () => { + harness.slots[kQuerySlot] = "gpt"; + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: [], + configuredOptions: [ + { key: "openai/gpt-5.5", label: "gpt-5.5" }, + { key: "openai/gpt-5.6-sol", label: "gpt-5.6-sol" }, + ], + }); + + expect(treeText(tree)).toContain( + "2 matching models are already added above.", + ); + }); + + it("shows the empty state when nothing matches", () => { + harness.slots[kQuerySlot] = "zzz-no-match"; + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: kOptions, + configuredOptions: [{ key: "openai/gpt-5.5", label: "gpt-5.5" }], + }); + + expect(treeText(tree)).toContain("No models match that search."); + }); + + it("stays closed and inert while disabled", () => { + const onSelect = vi.fn(); + harness.slots[kOpenSlot] = true; + const tree = renderPicker({ + options: kOptions, + onSelect, + disabled: true, + }); + + // Dropdown suppressed even though open state is true. + expect(findAllByType(tree, "button")).toHaveLength(0); + + const input = findAllByType(tree, "input")[0]; + harness.slots[kOpenSlot] = false; + input.props.onFocus(); + expect(harness.slots[kOpenSlot]).toBe(false); + + const preventDefault = vi.fn(); + input.props.onKeyDown({ key: "Enter", preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/frontend/models-tab-model-picker-helpers.test.js b/tests/frontend/models-tab-model-picker-helpers.test.js new file mode 100644 index 00000000..3ac499ab --- /dev/null +++ b/tests/frontend/models-tab-model-picker-helpers.test.js @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + buildProviderHasAuth, + buildSyntheticModelEntry, + getModelCatalogProvider, + getModelDisplayLabel, + getModelsTabAuthProvider, + getModelsTabRequiredAuthProviders, + getProviderAuthDisplayOrder, + getProviderSortIndex, +} from "../../lib/public/js/components/models-tab/model-picker.js"; + +describe("frontend/models-tab/model-picker helpers", () => { + it("maps non-OpenAI model providers to their auth provider", () => { + expect(getModelsTabAuthProvider("anthropic/claude-opus-4-6")).toBe( + "anthropic", + ); + expect(getModelsTabAuthProvider("google/gemini-3.1-pro-preview")).toBe( + "google", + ); + }); + + it("returns the single auth provider for non-OpenAI models", () => { + expect( + getModelsTabRequiredAuthProviders("anthropic/claude-opus-4-6"), + ).toEqual(["anthropic"]); + expect(getModelsTabRequiredAuthProviders("")).toEqual([]); + }); + + it("appends unknown required providers after the known display order", () => { + expect( + getProviderAuthDisplayOrder(["anthropic", "custom-lab"]), + ).toEqual(["anthropic", "openai-codex", "custom-lab"]); + }); + + it("resolves the catalog provider from explicit field or key", () => { + expect(getModelCatalogProvider({ provider: " openai " })).toBe("openai"); + expect(getModelCatalogProvider({ key: "google/gemini-3.1-pro" })).toBe( + "google", + ); + expect(getModelCatalogProvider(null)).toBe(""); + }); + + it("sorts providers by display order with unknowns last", () => { + expect(getProviderSortIndex("anthropic")).toBe(0); + expect(getProviderSortIndex("openai-codex")).toBe(2); + expect(getProviderSortIndex("not-a-provider")).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it("builds friendly Anthropic labels for synthetic entries", () => { + expect(buildSyntheticModelEntry("anthropic/claude-opus-4-6")).toEqual({ + key: "anthropic/claude-opus-4-6", + provider: "anthropic", + label: "Claude Opus 4.6", + }); + expect(buildSyntheticModelEntry("anthropic/claude-sonnet-4.6")).toEqual({ + key: "anthropic/claude-sonnet-4.6", + provider: "anthropic", + label: "Claude Sonnet 4.6", + }); + }); + + it("falls back to the raw key when no friendly label matches", () => { + expect(buildSyntheticModelEntry("anthropic/claude-custom")).toEqual({ + key: "anthropic/claude-custom", + provider: "anthropic", + label: "anthropic/claude-custom", + }); + expect(buildSyntheticModelEntry("plainmodel")).toEqual({ + key: "plainmodel", + provider: "plainmodel", + label: "plainmodel", + }); + expect(buildSyntheticModelEntry("")).toEqual({ + key: "", + provider: "", + label: "", + }); + }); + + it("prefers featured labels for display", () => { + expect(getModelDisplayLabel({ featuredLabel: "Featured" })).toBe( + "Featured", + ); + expect(getModelDisplayLabel({ key: "zai/glm-5" })).toBe("zai/glm-5"); + expect(getModelDisplayLabel(null)).toBe(""); + }); + + it("marks providers authenticated for key, token, or access profiles", () => { + expect( + buildProviderHasAuth({ + authProfiles: [ + { provider: "anthropic", token: "tok" }, + { provider: "google", access: "acc" }, + { provider: "zai" }, + ], + }), + ).toEqual({ anthropic: true, google: true }); + expect(buildProviderHasAuth()).toEqual({}); + expect(buildProviderHasAuth({})).toEqual({}); + }); +}); diff --git a/tests/frontend/session-keys-more.test.js b/tests/frontend/session-keys-more.test.js new file mode 100644 index 00000000..56ee05b8 --- /dev/null +++ b/tests/frontend/session-keys-more.test.js @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + getAgentIdFromSessionKey, + getDestinationFromSession, + getSessionChannelForIcon, + getSessionDisplayLabel, + getSessionKind, + getSessionPriority, + isDestinationSessionKey, + kDestinationSessionFilter, + parseChannelFromSessionKey, + sortSessionsByPriority, +} from "../../lib/public/js/lib/session-keys.js"; + +describe("session-keys destination and sorting helpers", () => { + it("extracts the agent id from a session key", () => { + expect(getAgentIdFromSessionKey("agent:bob:telegram:direct:1")).toBe("bob"); + expect(getAgentIdFromSessionKey("main")).toBe(""); + expect(getAgentIdFromSessionKey("")).toBe(""); + }); + + it("detects destination session keys", () => { + expect(isDestinationSessionKey("agent:a:telegram:DIRECT:123")).toBe(true); + expect(isDestinationSessionKey("agent:a:telegram:group:9")).toBe(true); + expect(isDestinationSessionKey("agent:a:main")).toBe(false); + }); + + it("filters destination sessions by reply metadata or key shape", () => { + expect( + kDestinationSessionFilter({ + key: "agent:a:main", + replyChannel: "telegram", + replyTo: "42", + }), + ).toBe(true); + expect( + kDestinationSessionFilter({ key: "agent:a:telegram:direct:42" }), + ).toBe(true); + expect(kDestinationSessionFilter({ key: "agent:a:main" })).toBe(false); + expect(kDestinationSessionFilter(null)).toBe(false); + }); + + it("prioritizes destination sessions ahead of others", () => { + expect(getSessionPriority({ key: "agent:a:telegram:direct:1" })).toBe(0); + expect(getSessionPriority({ key: "agent:a:main" })).toBe(1); + expect(getSessionPriority(null)).toBe(1); + }); + + it("sorts by priority, recency, then key", () => { + const sessions = [ + { key: "agent:a:main", updatedAt: 500 }, + { key: "agent:b:telegram:direct:2", updatedAt: 100 }, + { key: "agent:a:telegram:direct:1", updatedAt: 100 }, + { key: "agent:c:telegram:direct:3", updatedAt: 900 }, + ]; + expect(sortSessionsByPriority(sessions).map((row) => row.key)).toEqual([ + "agent:c:telegram:direct:3", + "agent:a:telegram:direct:1", + "agent:b:telegram:direct:2", + "agent:a:main", + ]); + expect(sortSessionsByPriority()).toEqual([]); + expect(sortSessionsByPriority("nope")).toEqual([]); + }); + + it("builds destinations from reply metadata", () => { + expect(getDestinationFromSession({ replyChannel: "telegram" })).toBe(null); + expect(getDestinationFromSession(null)).toBe(null); + expect( + getDestinationFromSession({ + key: "agent:bob:telegram:direct:7", + replyChannel: "telegram", + replyTo: "7", + }), + ).toEqual({ channel: "telegram", to: "7", agentId: "bob" }); + expect( + getDestinationFromSession({ + key: "standalone", + replyChannel: "slack", + replyTo: "C123", + }), + ).toEqual({ channel: "slack", to: "C123" }); + }); + + it("parses channels from session keys", () => { + expect(parseChannelFromSessionKey("agent:a:telegram:direct:1")).toBe( + "telegram", + ); + expect(parseChannelFromSessionKey("agent:a:discord:direct:1")).toBe( + "discord", + ); + expect(parseChannelFromSessionKey("agent:a:slack:direct:C1")).toBe("slack"); + expect(parseChannelFromSessionKey("agent:a:main")).toBe(""); + expect(parseChannelFromSessionKey()).toBe(""); + }); + + it("classifies session kinds", () => { + expect(getSessionKind("")).toBe("other"); + expect(getSessionKind("main")).toBe("main"); + expect(getSessionKind("agent:a:telegram:group:9:topic:4")).toBe("topic"); + expect(getSessionKind("agent:a:slash:cmd")).toBe("slash"); + expect(getSessionKind("agent:a:subagent:x")).toBe("subagent"); + expect(getSessionKind("agent:a:discord:direct:1")).toBe("direct"); + expect(getSessionKind("agent:a:something")).toBe("other"); + }); + + it("labels doctor and fallback sessions", () => { + expect(getSessionDisplayLabel({ key: "agent:a:doctor:12" })).toBe( + "Doctor Run #12", + ); + expect(getSessionDisplayLabel({ key: "agent:a:doctor" })).toBe( + "Doctor Run", + ); + expect(getSessionDisplayLabel({ key: "agent:a:custom-session" })).toBe( + "agent:a:custom-session", + ); + expect(getSessionDisplayLabel(null)).toBe("Session"); + expect(getSessionDisplayLabel({ key: "agent:a:discord:direct:99" })).toBe( + "Direct 99", + ); + expect(getSessionDisplayLabel({ key: "agent:a:telegram:direct:99" })).toBe( + "Direct message", + ); + }); + + it("resolves the channel icon source in preference order", () => { + expect( + getSessionChannelForIcon({ channel: "discord", replyChannel: "slack" }), + ).toBe("discord"); + expect(getSessionChannelForIcon({ replyChannel: "slack" })).toBe("slack"); + expect( + getSessionChannelForIcon({ key: "agent:a:telegram:direct:1" }), + ).toBe("telegram"); + expect(getSessionChannelForIcon(null)).toBe(""); + }); +}); diff --git a/tests/frontend/sse.test.js b/tests/frontend/sse.test.js new file mode 100644 index 00000000..d9af3531 --- /dev/null +++ b/tests/frontend/sse.test.js @@ -0,0 +1,148 @@ +const loadSseModule = async () => import("../../lib/public/js/lib/sse.js"); + +class FakeEventSource { + static instances = []; + + constructor(url, options) { + this.url = url; + this.options = options; + this.listeners = new Map(); + this.closed = false; + this.onerror = undefined; + FakeEventSource.instances.push(this); + } + + addEventListener(type, handler) { + const handlers = this.listeners.get(type) || []; + handlers.push(handler); + this.listeners.set(type, handlers); + } + + removeEventListener(type, handler) { + const handlers = (this.listeners.get(type) || []).filter( + (entry) => entry !== handler, + ); + this.listeners.set(type, handlers); + } + + close() { + this.closed = true; + } + + emit(type, event = {}) { + for (const handler of this.listeners.get(type) || []) handler(event); + } +} + +describe("frontend/sse", () => { + beforeEach(() => { + FakeEventSource.instances = []; + global.window = { EventSource: FakeEventSource }; + }); + + afterEach(() => { + delete global.window; + }); + + it("throws when EventSource is not available", async () => { + global.window = {}; + const { subscribeToSse } = await loadSseModule(); + + expect(() => subscribeToSse({ url: "/api/x" })).toThrow( + "Server events are not supported in this browser", + ); + }); + + it("opens a credentialed stream and forwards named events", async () => { + const { subscribeToSse } = await loadSseModule(); + const messages = []; + const onError = vi.fn(); + + subscribeToSse({ + url: "/api/operations/op-1/events", + onMessage: (message) => messages.push(message), + onError, + }); + + const source = FakeEventSource.instances[0]; + expect(source.url).toBe("/api/operations/op-1/events"); + expect(source.options).toEqual({ withCredentials: true }); + + source.emit("phase", { data: JSON.stringify({ phase: "cloning" }) }); + source.emit("done", { data: JSON.stringify({ ok: true }) }); + source.emit("error", { data: JSON.stringify({ error: "failed" }) }); + + expect(messages).toEqual([ + { event: "phase", data: { phase: "cloning" } }, + { event: "done", data: { ok: true } }, + { event: "error", data: { error: "failed" } }, + ]); + expect(onError).not.toHaveBeenCalled(); + }); + + it("normalizes missing, blank, non-string, and invalid payloads to empty objects", async () => { + const { subscribeToSse } = await loadSseModule(); + const messages = []; + + subscribeToSse({ + url: "/api/x", + onMessage: (message) => messages.push(message), + }); + + const source = FakeEventSource.instances[0]; + source.emit("phase", {}); + source.emit("phase", { data: " " }); + source.emit("phase", { data: 42 }); + source.emit("done", { data: "not json" }); + source.emit("error"); + + expect(messages).toEqual([ + { event: "phase", data: {} }, + { event: "phase", data: {} }, + { event: "phase", data: {} }, + { event: "done", data: {} }, + { event: "error", data: {} }, + ]); + }); + + it("invokes onError for transport errors and defaults callbacks", async () => { + const { subscribeToSse } = await loadSseModule(); + const onError = vi.fn(); + + subscribeToSse({ url: "/api/x", onError }); + const source = FakeEventSource.instances[0]; + const errorEvent = { type: "error" }; + source.onerror(errorEvent); + expect(onError).toHaveBeenCalledWith(errorEvent); + + // Defaults: no url, no callbacks — nothing should throw. + const unsubscribe = subscribeToSse({}); + const defaultSource = FakeEventSource.instances[1]; + expect(defaultSource.url).toBe(""); + expect(() => { + defaultSource.emit("phase", { data: "{}" }); + defaultSource.onerror({}); + }).not.toThrow(); + unsubscribe(); + }); + + it("unsubscribes listeners and closes the stream", async () => { + const { subscribeToSse } = await loadSseModule(); + const messages = []; + + const unsubscribe = subscribeToSse({ + url: "/api/x", + onMessage: (message) => messages.push(message), + }); + const source = FakeEventSource.instances[0]; + + unsubscribe(); + + expect(source.closed).toBe(true); + expect(source.onerror).toBe(null); + source.emit("phase", { data: "{}" }); + source.emit("done", { data: "{}" }); + source.emit("error", { data: "{}" }); + expect(messages).toEqual([]); + }); +}); diff --git a/tests/frontend/syntax-highlighters-markdown.test.js b/tests/frontend/syntax-highlighters-markdown.test.js new file mode 100644 index 00000000..26205909 --- /dev/null +++ b/tests/frontend/syntax-highlighters-markdown.test.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { highlightMarkdownContent } from "../../lib/public/js/lib/syntax-highlighters/markdown.js"; +import { + getFileSyntaxKind, + highlightEditorLines, +} from "../../lib/public/js/lib/syntax-highlighters/index.js"; + +const htmlForLine = (content) => highlightMarkdownContent(content)[0].html; + +describe("frontend/syntax-highlighters markdown", () => { + it("highlights headings, quotes, fences, and table separators", () => { + expect(htmlForLine("# Title")).toBe( + '# Title', + ); + expect(htmlForLine("### Deep ")).toBe( + '### Deep <b>', + ); + expect(htmlForLine("> quoted text")).toBe( + '> quoted text', + ); + expect(htmlForLine("```js")).toBe('```js'); + expect(htmlForLine("|---|---|")).toBe( + '|---|---|', + ); + }); + + it("highlights bullets with inline markdown", () => { + expect(htmlForLine("- item")).toBe('- item'); + expect(htmlForLine(" * starred")).toBe( + ' * starred', + ); + expect(htmlForLine("- has `code`")).toContain( + '`code`', + ); + }); + + it("highlights inline code, bold, and links on plain lines", () => { + const html = htmlForLine("say `hi` to **you** via [site](https://x.dev)"); + expect(html).toContain('`hi`'); + expect(html).toContain('**you**'); + expect(html).toContain( + '[site](https://x.dev)', + ); + }); + + it("escapes plain lines and keeps line numbering", () => { + const lines = highlightMarkdownContent("plain \nsecond & line"); + expect(lines).toHaveLength(2); + expect(lines[0]).toEqual({ lineNumber: 1, html: "plain <tag>" }); + expect(lines[1]).toEqual({ lineNumber: 2, html: "second & line" }); + }); +}); + +describe("frontend/syntax-highlighters editor line dispatch", () => { + it("falls back to plain for unknown extensions", () => { + expect(getFileSyntaxKind("notes/file.txt")).toBe("plain"); + expect(getFileSyntaxKind("")).toBe("plain"); + }); + + it("routes each syntax kind to its highlighter", () => { + expect(highlightEditorLines("# hi", "markdown")[0].html).toBe( + '# hi', + ); + expect( + highlightEditorLines("const x = 1;", "javascript")[0].html, + ).toContain("hl-"); + expect(highlightEditorLines("a { color: red; }", "css")[0].html).toContain( + "hl-", + ); + expect(highlightEditorLines("x < y", "plain")).toEqual([ + { lineNumber: 1, html: "x < y" }, + ]); + expect(highlightEditorLines("x < y", "unknown-kind")).toEqual([ + { lineNumber: 1, html: "x < y" }, + ]); + }); +}); diff --git a/tests/frontend/syntax-highlighters.test.js b/tests/frontend/syntax-highlighters.test.js index 37c4d01f..074dff72 100644 --- a/tests/frontend/syntax-highlighters.test.js +++ b/tests/frontend/syntax-highlighters.test.js @@ -39,3 +39,282 @@ describe("frontend/syntax-highlighters", () => { expect(lines[1].html).toContain('const'); }); }); + +const loadJavaScriptHighlighter = async () => + import("../../lib/public/js/lib/syntax-highlighters/javascript.js"); +const loadCssHighlighter = async () => + import("../../lib/public/js/lib/syntax-highlighters/css.js"); +const loadHtmlHighlighter = async () => + import("../../lib/public/js/lib/syntax-highlighters/html.js"); +const loadFrontmatter = async () => + import("../../lib/public/js/lib/syntax-highlighters/frontmatter.js"); + +describe("frontend/syntax-highlighters/javascript", () => { + it("highlights keywords, literals, numbers, strings, and comments", async () => { + const { highlightJavaScriptContent } = await loadJavaScriptHighlighter(); + const lines = highlightJavaScriptContent( + [ + "const x = 42; // trailing comment", + "let flag = true; const nothing = null; let missing = undefined; let off = false;", + "const hex = 0xFF; const exp = 1.5e10; const neg = -7;", + 'const dq = "double \\" quote";', + "const sq = 'single';", + "const tpl = `template`;", + "before /* inline */ typeof after", + ].join("\n"), + ); + + expect(lines).toHaveLength(7); + expect(lines[0].html).toContain('const'); + expect(lines[0].html).toContain('42'); + expect(lines[0].html).toContain('// trailing comment'); + expect(lines[1].html).toContain('true'); + expect(lines[1].html).toContain('false'); + expect(lines[1].html).toContain('null'); + expect(lines[1].html).toContain('undefined'); + expect(lines[2].html).toContain('0xFF'); + expect(lines[2].html).toContain('1.5e10'); + expect(lines[2].html).toContain('-7'); + expect(lines[3].html).toContain('"double \\" quote"'); + expect(lines[4].html).toContain("'single'"); + expect(lines[5].html).toContain('`template`'); + expect(lines[6].html).toContain('/* inline */'); + expect(lines[6].html).toContain('typeof'); + }); + + it("tracks block comments across lines and unterminated tokens", async () => { + const { highlightJavaScriptContent } = await loadJavaScriptHighlighter(); + const lines = highlightJavaScriptContent( + [ + "start /* spans", + "middle of comment", + "end */ return this;", + "plain text line", + 'const open = "unterminated', + "/* never closed", + ].join("\n"), + ); + + expect(lines[0].html).toContain('/* spans'); + expect(lines[1].html).toBe('middle of comment'); + expect(lines[2].html).toContain('end */'); + expect(lines[2].html).toContain('return'); + expect(lines[2].html).toContain('this'); + expect(lines[3].html).toContain("plain text line"); + expect(lines[4].html).toContain('"unterminated'); + expect(lines[5].html).toBe('/* never closed'); + }); + + it("handles escaped closing quotes at line end and default state", async () => { + const { highlightJavaScriptLine, highlightJavaScriptContent } = + await loadJavaScriptHighlighter(); + + const rendered = highlightJavaScriptLine("const a = 1"); + expect(rendered.html).toContain('const'); + expect(rendered.state).toEqual({ inBlockComment: false }); + + const trailingEscape = highlightJavaScriptLine('const s = "abc\\'); + expect(trailingEscape.html).toContain('class="hl-string"'); + + expect(highlightJavaScriptContent("")).toEqual([{ lineNumber: 1, html: "" }]); + expect(highlightJavaScriptContent(null)).toEqual([{ lineNumber: 1, html: "" }]); + }); +}); + +describe("frontend/syntax-highlighters/css", () => { + it("highlights at-rules, colors, numbers, units, and properties", async () => { + const { highlightCssContent } = await loadCssHighlighter(); + const lines = highlightCssContent( + [ + "@media (min-width: 600px) {", + " .box { color: #ff0000; margin: 10px 1.5em; width: 100%; }", + "}", + ].join("\n"), + ); + + expect(lines[0].html).toContain('@media'); + expect(lines[0].html).toContain('600px'); + expect(lines[1].html).toContain('color'); + expect(lines[1].html).toContain('margin'); + expect(lines[1].html).toContain('#ff0000'); + expect(lines[1].html).toContain('10px'); + expect(lines[1].html).toContain('1.5em'); + expect(lines[1].html).toContain('100%'); + }); + + it("tracks comments and strings across lines", async () => { + const { highlightCssContent } = await loadCssHighlighter(); + const lines = highlightCssContent( + [ + "before /* inline */ after", + "start /* spans", + "still comment", + 'end */ body { background: url("img.png"); }', + "content: 'quoted \\' esc';", + 'broken: "unterminated', + "/* never closed", + ].join("\n"), + ); + + expect(lines[0].html).toContain('/* inline */'); + expect(lines[1].html).toContain('/* spans'); + expect(lines[2].html).toBe('still comment'); + expect(lines[3].html).toContain('end */'); + expect(lines[3].html).toContain('"img.png"'); + expect(lines[4].html).toContain("hl-string"); + expect(lines[5].html).toContain('"unterminated'); + expect(lines[6].html).toBe('/* never closed'); + }); + + it("uses a default state and tolerates empty content", async () => { + const { highlightCssLine, highlightCssContent } = await loadCssHighlighter(); + + const rendered = highlightCssLine("a { b: 1; }"); + expect(rendered.html).toContain('b'); + expect(rendered.state).toEqual({ inBlockComment: false }); + + expect(highlightCssContent("")).toEqual([{ lineNumber: 1, html: "" }]); + }); +}); + +describe("frontend/syntax-highlighters/html", () => { + it("highlights doctype, comments, tags, attributes, and entities", async () => { + const { highlightHtmlContent } = await loadHtmlHighlighter(); + const lines = highlightHtmlContent( + [ + "", + "", + "", + "
", + "text bold tail", + '
', + ].join("\n"), + ); + + expect(lines[0].html).toBe('<!DOCTYPE html>'); + expect(lines[1].html).toBe('<!-- a comment -->'); + expect(lines[2].html).toContain('div'); + expect(lines[2].html).toContain('class'); + expect(lines[2].html).toContain('"box"'); + expect(lines[2].html).toContain("'main'"); + expect(lines[2].html).toContain('5'); + expect(lines[2].html).toContain('hidden'); + expect(lines[2].html).toContain('&amp;'); + expect(lines[3].html).toContain('/>'); + expect(lines[4].html).toContain("text "); + expect(lines[4].html).toContain(" tail"); + expect(lines[4].html).toContain('</'); + expect(lines[5].html).toContain('a'); + expect(lines[5].html).toContain('='); + expect(lines[5].html).toContain('"v"'); + expect(lines[5].html).toContain("~"); + }); + + it("switches into script and style modes across lines", async () => { + const { highlightHtmlContent } = await loadHtmlHighlighter(); + const lines = highlightHtmlContent( + [ + "", + "", + ].join("\n"), + ); + + expect(lines[0].html).toContain('style'); + expect(lines[1].html).toContain('color'); + expect(lines[2].html).toContain(''); + expect(lines[3].html).toContain('style'); + expect(lines[4].html).toContain('script'); + expect(lines[5].html).toContain('const'); + expect(lines[5].html).toContain('// js'); + expect(lines[6].html).toContain('class="hl-string"'); + expect(lines[7].html).toContain('script'); + }); + + it("handles inline script/style blocks that open and close on one line", async () => { + const { highlightHtmlContent } = await loadHtmlHighlighter(); + const lines = highlightHtmlContent( + [ + "after", + "", + "", + "