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")
|
const previousDir = path.join(uiRoot, "previous")
|
||||||
|
|
||||||
if (!options.autoUpdate) {
|
if (!options.autoUpdate) {
|
||||||
const local = await resolveStaticUiDir(currentDir)
|
return await resolveFromCacheOrBundled({
|
||||||
if (local) {
|
logger: options.logger,
|
||||||
return {
|
bundledUiDir: options.bundledUiDir,
|
||||||
uiStaticDir: local,
|
currentDir,
|
||||||
source: "downloaded",
|
previousDir,
|
||||||
uiVersion: await readUiVersion(local),
|
|
||||||
supported: true,
|
supported: true,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const bundled = await resolveStaticUiDir(options.bundledUiDir)
|
|
||||||
return {
|
|
||||||
uiStaticDir: bundled ?? options.bundledUiDir,
|
|
||||||
source: bundled ? "bundled" : "missing",
|
|
||||||
uiVersion: bundled ? await readUiVersion(bundled) : undefined,
|
|
||||||
supported: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest: RemoteUiManifest | null = null
|
let manifest: RemoteUiManifest | null = null
|
||||||
@@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = await readUiVersion(currentDir)
|
const bestLocal = await pickBestLocalUi({
|
||||||
if (currentVersion && currentVersion === manifest.latestUIVersion) {
|
logger: options.logger,
|
||||||
const currentResolved = await resolveStaticUiDir(currentDir)
|
bundledUiDir: options.bundledUiDir,
|
||||||
if (currentResolved) {
|
currentDir,
|
||||||
return {
|
previousDir,
|
||||||
uiStaticDir: currentResolved,
|
})
|
||||||
source: "downloaded",
|
|
||||||
uiVersion: currentVersion,
|
const remoteIsNewer =
|
||||||
|
!bestLocal ||
|
||||||
|
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||||
|
|
||||||
|
if (!remoteIsNewer) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
supported: true,
|
supported: true,
|
||||||
latestServerVersion: manifest.latestServerVersion,
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
latestServerUrl: manifest.latestServerUrl,
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
minServerVersion: manifest.minServerVersion,
|
minServerVersion: manifest.minServerVersion,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: {
|
|||||||
latestServerUrl?: string
|
latestServerUrl?: string
|
||||||
minServerVersion?: string
|
minServerVersion?: string
|
||||||
}): Promise<UiResolution> {
|
}): Promise<UiResolution> {
|
||||||
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
const bestLocal = await pickBestLocalUi({
|
||||||
if (currentResolved) {
|
logger: args.logger,
|
||||||
return {
|
bundledUiDir: args.bundledUiDir,
|
||||||
uiStaticDir: currentResolved,
|
currentDir: args.currentDir,
|
||||||
source: "downloaded",
|
previousDir: args.previousDir,
|
||||||
uiVersion: await readUiVersion(currentResolved),
|
})
|
||||||
supported: args.supported,
|
|
||||||
message: args.message,
|
|
||||||
latestServerVersion: args.latestServerVersion,
|
|
||||||
latestServerUrl: args.latestServerUrl,
|
|
||||||
minServerVersion: args.minServerVersion,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
if (bestLocal) {
|
||||||
if (previousResolved) {
|
|
||||||
return {
|
return {
|
||||||
uiStaticDir: previousResolved,
|
uiStaticDir: bestLocal.uiStaticDir,
|
||||||
source: "previous",
|
source: bestLocal.source,
|
||||||
uiVersion: await readUiVersion(previousResolved),
|
uiVersion: bestLocal.uiVersion,
|
||||||
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),
|
|
||||||
supported: args.supported,
|
supported: args.supported,
|
||||||
message: args.message,
|
message: args.message,
|
||||||
latestServerVersion: args.latestServerVersion,
|
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> {
|
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const indexPath = path.join(uiDir, "index.html")
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
|||||||
Reference in New Issue
Block a user