diff --git a/packages/server/src/ui/__tests__/remote-ui.test.ts b/packages/server/src/ui/__tests__/remote-ui.test.ts new file mode 100644 index 00000000..e858498d --- /dev/null +++ b/packages/server/src/ui/__tests__/remote-ui.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdir } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, it } from "node:test" + +import type { Logger } from "../../logger" +import { resolveUi } from "../remote-ui" + +const noopLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + trace: () => {}, + child: () => noopLogger, + isLevelEnabled: () => false, +} as any + +let tempRoot: string + +beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-")) +}) + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) +}) + +describe("resolveUi local version preference", () => { + it("prefers bundled when bundled version is higher", async () => { + const bundledDir = path.join(tempRoot, "bundled") + const configDir = path.join(tempRoot, "config") + const currentDir = path.join(configDir, "ui", "current") + + await mkdir(bundledDir, { recursive: true }) + await mkdir(currentDir, { recursive: true }) + + writeFileSync(path.join(bundledDir, "index.html"), "bundled") + writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" })) + + writeFileSync(path.join(currentDir, "index.html"), "current") + writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" })) + + const result = await resolveUi({ + serverVersion: "0.8.1", + bundledUiDir: bundledDir, + autoUpdate: false, + configDir, + logger: noopLogger, + }) + + assert.equal(result.source, "bundled") + assert.equal(result.uiStaticDir, bundledDir) + assert.equal(result.uiVersion, "0.8.1") + }) +}) diff --git a/packages/server/src/ui/remote-ui.ts b/packages/server/src/ui/remote-ui.ts index 45e0e544..1aff87df 100644 --- a/packages/server/src/ui/remote-ui.ts +++ b/packages/server/src/ui/remote-ui.ts @@ -73,23 +73,13 @@ export async function resolveUi(options: RemoteUiOptions): Promise const previousDir = path.join(uiRoot, "previous") if (!options.autoUpdate) { - const local = await resolveStaticUiDir(currentDir) - if (local) { - return { - uiStaticDir: local, - source: "downloaded", - uiVersion: await readUiVersion(local), - supported: true, - } - } - - const bundled = await resolveStaticUiDir(options.bundledUiDir) - return { - uiStaticDir: bundled ?? options.bundledUiDir, - source: bundled ? "bundled" : "missing", - uiVersion: bundled ? await readUiVersion(bundled) : undefined, + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, supported: true, - } + }) } let manifest: RemoteUiManifest | null = null @@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise }) } - const currentVersion = await readUiVersion(currentDir) - if (currentVersion && currentVersion === manifest.latestUIVersion) { - const currentResolved = await resolveStaticUiDir(currentDir) - if (currentResolved) { - return { - uiStaticDir: currentResolved, - source: "downloaded", - uiVersion: currentVersion, - supported: true, - latestServerVersion: manifest.latestServerVersion, - latestServerUrl: manifest.latestServerUrl, - minServerVersion: manifest.minServerVersion, - } - } + const bestLocal = await pickBestLocalUi({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + }) + + const remoteIsNewer = + !bestLocal || + compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0 + + if (!remoteIsNewer) { + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + }) } try { @@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: { latestServerUrl?: string minServerVersion?: string }): Promise { - const currentResolved = await resolveStaticUiDir(args.currentDir) - if (currentResolved) { - return { - uiStaticDir: currentResolved, - source: "downloaded", - uiVersion: await readUiVersion(currentResolved), - supported: args.supported, - message: args.message, - latestServerVersion: args.latestServerVersion, - latestServerUrl: args.latestServerUrl, - minServerVersion: args.minServerVersion, - } - } + const bestLocal = await pickBestLocalUi({ + logger: args.logger, + bundledUiDir: args.bundledUiDir, + currentDir: args.currentDir, + previousDir: args.previousDir, + }) - const previousResolved = await resolveStaticUiDir(args.previousDir) - if (previousResolved) { + if (bestLocal) { return { - uiStaticDir: previousResolved, - source: "previous", - uiVersion: await readUiVersion(previousResolved), - supported: args.supported, - message: args.message, - latestServerVersion: args.latestServerVersion, - latestServerUrl: args.latestServerUrl, - minServerVersion: args.minServerVersion, - } - } - - const bundledResolved = await resolveStaticUiDir(args.bundledUiDir) - if (bundledResolved) { - return { - uiStaticDir: bundledResolved, - source: "bundled", - uiVersion: await readUiVersion(bundledResolved), + uiStaticDir: bestLocal.uiStaticDir, + source: bestLocal.source, + uiVersion: bestLocal.uiVersion, supported: args.supported, message: args.message, latestServerVersion: args.latestServerVersion, @@ -260,6 +236,66 @@ async function resolveFromCacheOrBundled(args: { } } +async function pickBestLocalUi(args: { + logger: Logger + bundledUiDir: string + currentDir: string + previousDir: string +}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> { + const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = [] + + const currentResolved = await resolveStaticUiDir(args.currentDir) + if (currentResolved) { + candidates.push({ + uiStaticDir: currentResolved, + source: "downloaded", + uiVersion: await readUiVersion(currentResolved), + priority: 2, + }) + } + + const bundledResolved = await resolveStaticUiDir(args.bundledUiDir) + if (bundledResolved) { + candidates.push({ + uiStaticDir: bundledResolved, + source: "bundled", + uiVersion: await readUiVersion(bundledResolved), + priority: 1, + }) + } + + const previousResolved = await resolveStaticUiDir(args.previousDir) + if (previousResolved) { + candidates.push({ + uiStaticDir: previousResolved, + source: "previous", + uiVersion: await readUiVersion(previousResolved), + priority: 0, + }) + } + + if (candidates.length === 0) { + return null + } + + candidates.sort((a, b) => { + const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion) + if (versionCmp !== 0) return -versionCmp + return b.priority - a.priority + }) + + const best = candidates[0] + if (!best) return null + return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion } +} + +function compareSemverMaybe(a: string | undefined, b: string | undefined): number { + if (!a && !b) return 0 + if (!a) return -1 + if (!b) return 1 + return compareSemverCore(a, b) +} + async function resolveStaticUiDir(uiDir: string): Promise { try { const indexPath = path.join(uiDir, "index.html")