fix(server): prefer highest available UI version
Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release.
This commit is contained in:
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
@@ -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"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -73,23 +73,13 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
|
||||
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<UiResolution>
|
||||
})
|
||||
}
|
||||
|
||||
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<UiResolution> {
|
||||
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<string | null> {
|
||||
try {
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
|
||||
Reference in New Issue
Block a user