diff --git a/README.md b/README.md index e676ba6..bc086af 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,21 @@ exactly that build into `browsers//-/` inside that directo library uses, so both can share one cache. If the build is missing, the first launch downloads it, unless `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` is set. +Other builds can be chosen with the same commands as in the +[Python library](https://github.com/daijro/camoufox/tree/main/pythonlib#fetch), limited to the builds this release +supports: + +```bash +npx camoufox-js sync # refresh the list of available builds +npx camoufox-js list all # show them (`list` shows installed ones) +npx camoufox-js set official/stable # follow the latest stable build +npx camoufox-js set official/stable/152.0.4-beta.30 # or pin one +npx camoufox-js fetch # install the chosen build +npx camoufox-js active # print the build launches use +npx camoufox-js set --release # go back to the build this release is tested with +npx camoufox-js remove official/stable/152.0.4-beta.30 # remove one build (`remove` removes everything) +``` + ## Usage You can launch Playwright-controlled Camoufox using this package like this: diff --git a/src/__main__.ts b/src/__main__.ts index c108a1a..c7b58af 100644 --- a/src/__main__.ts +++ b/src/__main__.ts @@ -1,10 +1,39 @@ #!/usr/bin/env node import { existsSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; import { Command } from "commander"; import { DefaultAddons, maybeDownloadAddons } from "./addons.js"; +import { effectivePin, loadPin, pinMatches, pinSpec } from "./browser-pin.js"; +import { CamoufoxNotInstalled, UnsupportedVersion } from "./exceptions.js"; import { ALLOW_GEOIP, downloadMMDB, removeMMDB } from "./locale.js"; -import { CamoufoxFetcher, INSTALL_DIR, installedVerStr } from "./pkgman.js"; +import { + type CachedVersion, + channelPath, + findInstalled, + getActivePath, + isSupported, + latestInChannel, + latestPerBuild, + listInstalled, + loadCachedVersions, + loadConfig, + parseSpecifier, + removeVersion, + resolveSpec, + saveCachedVersions, + saveConfig, + selectBuild, +} from "./multiversion.js"; +import { + CamoufoxFetcher, + camoufoxPath, + INSTALL_DIR, + installedVerStr, + listAvailableVersions, + SUPPORTED_RANGE, + Version, +} from "./pkgman.js"; import { launchServer } from "./server.js"; import { Camoufox } from "./sync_api.js"; import { getAsBooleanFromENV } from "./utils.js"; @@ -12,8 +41,8 @@ import { getAsBooleanFromENV } from "./utils.js"; class CamoufoxUpdate extends CamoufoxFetcher { currentVerStr: string | null; - private constructor() { - super(); + private constructor(selected?: CachedVersion) { + super(selected); this.currentVerStr = null; try { this.currentVerStr = installedVerStr(); @@ -26,8 +55,8 @@ class CamoufoxUpdate extends CamoufoxFetcher { } } - static async create(): Promise { - const updater = new CamoufoxUpdate(); + static async create(selected?: CachedVersion): Promise { + const updater = new CamoufoxUpdate(selected); await updater.init(); return updater; } @@ -65,7 +94,6 @@ class CamoufoxUpdate extends CamoufoxFetcher { if (this.currentVerStr !== null) { console.log( `Updating Camoufox binaries from v${this.currentVerStr} => v${this.verstr}`, - "yellow", ); } else { console.log(`Fetching Camoufox binaries...`); @@ -83,24 +111,275 @@ class CamoufoxUpdate extends CamoufoxFetcher { } } -const program = new Command(); +async function sync(): Promise { + console.log("Syncing available Camoufox builds..."); + const versions = await listAvailableVersions(); + saveCachedVersions(versions); + console.log( + `Synced ${versions.length} builds, ${versions.filter(isSupported).length} of them supported by this release.`, + ); + return versions; +} -program.command("fetch").action(async () => { - const updater = await CamoufoxUpdate.create(); - await updater.update(); - if (ALLOW_GEOIP) { - await downloadMMDB(); +function printActive(): void { + const config = loadConfig(); + const pin = effectivePin(config); + const installed = listInstalled(); + let selection: string; + let target: (typeof installed)[number] | undefined; + if (pin) { + target = installed.find((v) => + pinMatches(pin, v.repoName, v.version.version ?? "", v.version.release), + ); + selection = `${target ? channelPath(target) : `${pin.repoName}/${pinSpec(pin)}`} (tested with this release)`; + } else if (config.pinned) { + target = installed.find( + (v) => + v.version.fullString === config.pinned && + (!config.pinned_sha || v.sha256 === config.pinned_sha), + ); + selection = `${config.channel}/${config.pinned}`; + } else { + const launchDir = getActivePath(); + target = installed.find((v) => v.path === launchDir); + selection = target + ? channelPath(target) + : (config.channel ?? "official/stable"); } - await maybeDownloadAddons(DefaultAddons); -}); - -program.command("remove").action(async () => { - const updater = await CamoufoxUpdate.create(); - if (!(await updater.cleanup())) { - console.log("Camoufox binaries not found!", "red"); + if (target) { + console.log(selection); + return; } - removeMMDB(); -}); + try { + // A flat install of an older release. + console.log( + `${selection} (not fetched), launching v${installedVerStr()} from ${camoufoxPath()}`, + ); + } catch { + console.log(`${selection} (not fetched)`); + } +} + +const program = new Command(); + +program + .command("fetch") + .description( + "Install the build this release is tested with, the one chosen with `set`, or the given one", + ) + .argument( + "[version]", + "A build (152.0.4-beta.30, official/stable/152.0.4-beta.30) or a channel (official/stable)", + ) + .action(async (version?: string) => { + let selected: CachedVersion | undefined; + if (!getAsBooleanFromENV("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", false)) { + selected = selectBuild(await sync(), version); + } + const updater = await CamoufoxUpdate.create(selected); + await updater.update(); + if (ALLOW_GEOIP) { + await downloadMMDB(); + } + let launchVersion: string | undefined; + try { + launchVersion = Version.fromPath(camoufoxPath()).fullString; + } catch (e) { + if ( + !(e instanceof CamoufoxNotInstalled || e instanceof UnsupportedVersion) + ) { + throw e; + } + } + // `fetch ` doesn't change which build launches use. + if (version && selected && launchVersion !== updater.verstr) { + console.log( + `Launches don't use v${updater.verstr}, run \`camoufox set ${updater.verstr}\` to switch to it.`, + ); + } + if (launchVersion) { + await maybeDownloadAddons(DefaultAddons); + } + }); + +program + .command("remove") + .description("Remove all downloaded data, or only the given installed build") + .argument( + "[version]", + "An installed build, e.g. official/stable/152.0.4-beta.30", + ) + .action(async (version?: string) => { + if (version) { + const target = findInstalled(version); + if (!target) { + throw new Error(`Version '${version}' is not installed.`); + } + removeVersion(target); + console.log(`Removed ${channelPath(target)}.`); + return; + } + if (existsSync(INSTALL_DIR)) { + rmSync(INSTALL_DIR, { recursive: true, force: true }); + console.log("Camoufox binaries removed!"); + } else { + console.log("Camoufox binaries not found!"); + } + removeMMDB(); + }); + +program + .command("sync") + .description("Refresh the list of available builds") + .action(async () => { + await sync(); + }); + +program + .command("set") + .description( + "Choose the build to fetch and launch instead of the one this release is tested with", + ) + .argument( + "[specifier]", + "A channel (official/stable, official/prerelease) or a build (152.0.4-beta.30, official/stable/152.0.4-beta.30)", + ) + .option("--release", "Go back to the build this release is tested with") + .action((specifier: string | undefined, options: { release?: boolean }) => { + const { channel, pinned, pinned_sha, active_version, ...config } = + loadConfig(); + if (options.release) { + saveConfig(config); + const pin = loadPin(); + console.log( + `Using the build this release is tested with${pin ? `, v${pinSpec(pin)}` : ""}. Run \`camoufox fetch\` if it isn't installed.`, + ); + return; + } + if (!specifier) { + printActive(); + return; + } + + const parsed = parseSpecifier(specifier); + const versions = loadCachedVersions(); + if (!parsed.spec) { + const latest = + versions && latestInChannel(versions, parsed.channel ?? "stable"); + const installed = + latest && + listInstalled().find( + (v) => v.version.fullString === `${latest.version}-${latest.build}`, + ); + saveConfig({ + ...config, + channel: `official/${parsed.channel}`, + ...(installed && { active_version: installed.relativePath }), + }); + console.log(`Channel: official/${parsed.channel}`); + console.log( + installed + ? `Using ${channelPath(installed)} (installed).` + : "Run `camoufox fetch` to install its latest build.", + ); + return; + } + + if (!versions) { + throw new Error( + "No list of available builds yet, run `camoufox sync` first.", + ); + } + const resolved = resolveSpec(versions, parsed.spec); + if (!resolved) { + throw new Error( + `Version '${parsed.spec}' was not found, run \`camoufox sync\` to refresh the list.`, + ); + } + const { entry, sha256 } = resolved; + if (!isSupported(entry)) { + throw new UnsupportedVersion( + `Camoufox v${entry.version}-${entry.build} is not supported by this release (supported range: ${SUPPORTED_RANGE}).`, + ); + } + const versionBuild = `${entry.version}-${entry.build}`; + const ctype = entry.is_prerelease ? "prerelease" : "stable"; + const installed = listInstalled().find( + (v) => + v.version.fullString === versionBuild && + (!sha256 || v.sha256 === sha256), + ); + saveConfig({ + ...config, + channel: `official/${ctype}`, + pinned: versionBuild, + ...(sha256 && { pinned_sha: sha256 }), + ...(installed && { active_version: installed.relativePath }), + }); + console.log( + `Pinned: official/${ctype}/${versionBuild}${installed ? " (installed)" : ""}`, + ); + if (!installed) console.log("Run `camoufox fetch` to install it."); + }); + +program + .command("active") + .description("Print the build launches use") + .action(printActive); + +program + .command("list") + .description("List installed builds, or all available ones") + .argument("[mode]", "installed or all", "installed") + .option("--path", "Show install paths") + .action((mode: string, options: { path?: boolean }) => { + const launchDir = getActivePath(); + const installed = listInstalled(); + if (mode === "all") { + const versions = loadCachedVersions(); + if (!versions) { + throw new Error( + "No list of available builds yet, run `camoufox sync` first.", + ); + } + console.log("official/"); + for (const v of latestPerBuild(versions)) { + const versionBuild = `${v.version}-${v.build}`; + const inst = installed.find( + (i) => i.version.fullString === versionBuild, + ); + const tags = [ + v.is_prerelease ? "prerelease" : "stable", + ...(isSupported(v) ? [] : ["unsupported"]), + ...(inst + ? [inst.path === launchDir ? "installed, active" : "installed"] + : []), + ]; + console.log( + ` v${versionBuild} (${tags.join(") (")})${options.path && inst ? ` -> ${inst.path}` : ""}`, + ); + } + return; + } + if (installed.length === 0) { + console.log("No versions installed. Run `camoufox fetch` to install."); + return; + } + let repo: string | undefined; + for (const v of installed) { + if (v.repoName !== repo) { + repo = v.repoName; + console.log(`${repo}/`); + } + const tags = [ + v.isPrerelease ? "prerelease" : "stable", + ...(v.path === launchDir ? ["active"] : []), + ]; + console.log( + ` v${v.version.fullString} (${tags.join(") (")})${options.path ? ` -> ${v.path}` : ""}`, + ); + } + }); program .command("test") @@ -136,27 +415,22 @@ program.command("path").action(() => { }); program.command("version").action(async () => { - try { - const pkgVersion = require("pkg-version"); - console.log(`Pip package:\tv${pkgVersion("camoufox")}`); - } catch (_error) { - console.log("Pip package:\tNot installed!", "red"); - } + const { version } = createRequire(import.meta.url)("../package.json"); + console.log(`camoufox-js:\tv${version}`); const updater = await CamoufoxUpdate.create(); const binVer = updater.currentVerStr; if (!binVer) { - console.log("Camoufox:\tNot downloaded!", "red"); + console.log("Camoufox:\tNot downloaded!"); return; } - console.log(`Camoufox:\tv${binVer} `, "green", false); - - if (updater.isUpdateNeeded()) { - console.log(`(Latest supported: v${updater.verstr})`, "red"); - } else { - console.log("(Up to date!)", "yellow"); - } + console.log( + `Camoufox:\tv${binVer} ${updater.isUpdateNeeded() ? `(Latest supported: v${updater.verstr})` : "(Up to date!)"}`, + ); }); -program.parse(process.argv); +program.parseAsync(process.argv).catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exitCode = 1; +}); diff --git a/src/multiversion.ts b/src/multiversion.ts index 5d00000..fa20670 100644 --- a/src/multiversion.ts +++ b/src/multiversion.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { effectivePin, pinMatches } from "./browser-pin.js"; -import { INSTALL_DIR, Version } from "./pkgman.js"; +import { effectivePin, pinMatches, pinSpec } from "./browser-pin.js"; +import { UnsupportedVersion } from "./exceptions.js"; +import { INSTALL_DIR, SUPPORTED_RANGE, Version } from "./pkgman.js"; /** * The Python library's multi-version layout (multiversion.py), shared with it: builds live in @@ -144,3 +145,237 @@ export function getActivePath(): string | null { return null; } + +export const repoCacheFile = (): string => + path.join(INSTALL_DIR.toString(), "repo_cache.json"); + +/** + * A release asset as the Python library stores it in repo_cache.json. + */ +export interface CachedVersion { + version: string; + build: string; + url: string; + is_prerelease: boolean; + asset_id?: number; + asset_size?: number; + asset_updated_at?: string; + sha256?: string | null; + created_at?: string; +} + +interface RepoCache { + repos?: { name: string; repo?: string; versions?: CachedVersion[] }[]; + [key: string]: unknown; +} + +function loadRepoCache(): RepoCache | null { + try { + return JSON.parse(fs.readFileSync(repoCacheFile(), "utf-8")); + } catch { + return null; + } +} + +/** + * The official repo's builds from the last `camoufox sync`, or null if it never ran. + */ +export function loadCachedVersions(): CachedVersion[] | null { + const cache = loadRepoCache(); + if (!cache) return null; + return ( + cache.repos?.find((r) => r.name.toLowerCase() === "official")?.versions ?? + [] + ); +} + +/** + * Replaces the official repo's builds, keeping whatever else the Python library synced. + */ +export function saveCachedVersions(versions: CachedVersion[]): void { + const cache = loadRepoCache() ?? {}; + const others = (cache.repos ?? []).filter( + (r) => r.name.toLowerCase() !== "official", + ); + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + fs.writeFileSync( + repoCacheFile(), + JSON.stringify( + { + ...cache, + repos: [ + { name: "Official", repo: "daijro/camoufox", versions }, + ...others, + ], + }, + null, + 2, + ), + ); +} + +/** + * One entry per version-build, the most recently uploaded asset. + */ +export function latestPerBuild(versions: CachedVersion[]): CachedVersion[] { + const best = new Map(); + for (const v of versions) { + const key = `${v.version}-${v.build}`; + const current = best.get(key); + if (!current || (v.created_at ?? "") > (current.created_at ?? "")) { + best.set(key, v); + } + } + return [...best.values()]; +} + +/** + * Resolves `-` (its newest asset) or `--` (that asset). + */ +export function resolveSpec( + versions: CachedVersion[], + spec: string, +): { entry: CachedVersion; sha256?: string } | null { + for (const v of versions) { + if ( + v.sha256 && + spec === `${v.version}-${v.build}-${v.sha256.slice(0, 8)}` + ) { + return { entry: v, sha256: v.sha256 }; + } + } + const entry = latestPerBuild(versions).find( + (v) => spec === `${v.version}-${v.build}`, + ); + return entry ? { entry } : null; +} + +export function channelPath(v: InstalledVersion): string { + const channel = v.isPrerelease ? "prerelease" : "stable"; + return `${v.repoName}/${channel}/${v.version.fullString}`; +} + +/** + * Finds an installed build by channel path, relative path, build, full version or + * repo/version, or the newest installed build of a repo/channel. + */ +export function findInstalled(specifier: string): InstalledVersion | null { + const spec = specifier.toLowerCase(); + const parts = spec.split("/"); + const installed = listInstalled(); + for (const v of installed) { + if ( + [ + channelPath(v), + v.relativePath, + v.version.release, + v.version.fullString, + ].some((s) => s.toLowerCase() === spec) || + (parts.length === 2 && + v.repoName === parts[0] && + v.version.fullString.toLowerCase() === parts[1]) + ) { + return v; + } + } + if (parts.length === 2 && ["stable", "prerelease"].includes(parts[1])) { + const isPrerelease = parts[1] === "prerelease"; + return ( + installed.find( + (v) => v.repoName === parts[0] && v.isPrerelease === isPrerelease, + ) ?? null + ); + } + return null; +} + +export function removeVersion(v: InstalledVersion): void { + fs.rmSync(v.path, { recursive: true, force: true }); + const repoDir = path.dirname(v.path); + if (fs.readdirSync(repoDir).length === 0) fs.rmdirSync(repoDir); + const config = loadConfig(); + if (config.active_version === v.relativePath) { + saveConfig({ + ...config, + active_version: listInstalled()[0]?.relativePath ?? null, + }); + } +} + +export const isSupported = (v: CachedVersion) => + new Version(v.build, v.version).isSupported(); + +// The Python CLI's specifiers: `-[-]`, `/`, +// `/-` and `//-`. +export function parseSpecifier(specifier: string): { + channel?: string; + spec?: string; +} { + const parts = specifier.toLowerCase().split("/"); + const spec = parts.length > 1 ? parts.at(-1) : parts[0]; + const channel = parts.length === 3 ? parts[1] : spec; + if (parts.length > 3 || (parts.length > 1 && parts[0] !== "official")) { + throw new Error( + `Invalid specifier '${specifier}'. camoufox-js only installs builds of the official repo, e.g. official/stable or official/stable/152.0.4-beta.30.`, + ); + } + if (channel === "stable" || channel === "prerelease") { + return parts.length === 3 + ? { channel, spec: spec?.replace(/^v/, "") } + : { channel }; + } + if (parts.length === 3) { + throw new Error( + `Unknown channel '${channel}'. Use 'stable' or 'prerelease'.`, + ); + } + return { spec: spec?.replace(/^v/, "") }; +} + +export const latestInChannel = (versions: CachedVersion[], channel: string) => + versions.find( + (v) => v.is_prerelease === (channel === "prerelease") && isSupported(v), + ); + +/** + * The given build, else the pinned build or the latest of the channel chosen with `set`, + * else the build this release is tested with. + */ +export function selectBuild( + versions: CachedVersion[], + specifier?: string, +): CachedVersion { + const config = loadConfig(); + const pin = effectivePin(config); + let wanted: string; + let target: CachedVersion | undefined; + if (specifier) { + const { channel, spec } = parseSpecifier(specifier); + wanted = specifier; + target = spec + ? resolveSpec(versions, spec)?.entry + : latestInChannel(versions, channel ?? "stable"); + } else if (config.pinned) { + wanted = `${config.channel}/${config.pinned}`; + target = config.pinned_sha + ? versions.find((v) => v.sha256 === config.pinned_sha) + : resolveSpec(versions, config.pinned)?.entry; + } else if (pin) { + wanted = pinSpec(pin); + target = resolveSpec(versions, wanted)?.entry; + } else { + wanted = config.channel ?? "official/stable"; + target = latestInChannel(versions, wanted.split("/")[1] ?? "stable"); + } + if (!target) { + throw new Error( + `Camoufox ${wanted} was not found in the official repo's releases for this platform.`, + ); + } + if (!isSupported(target)) { + throw new UnsupportedVersion( + `Camoufox v${target.version}-${target.build} is not supported by this release (supported range: ${SUPPORTED_RANGE}).`, + ); + } + return target; +} diff --git a/src/pkgman.ts b/src/pkgman.ts index 0ea2c26..9d43db1 100644 --- a/src/pkgman.ts +++ b/src/pkgman.ts @@ -12,7 +12,7 @@ import AdmZip from "adm-zip"; import cliProgress, { type Options } from "cli-progress"; import prettyBytes from "pretty-bytes"; import { CONSTRAINTS } from "./__version__.js"; -import { effectivePin, pinMatches, pinSpec } from "./browser-pin.js"; +import { effectivePin, loadPin, pinMatches, pinSpec } from "./browser-pin.js"; import { CamoufoxNotInstalled, CorruptedDownload, @@ -24,6 +24,7 @@ import { } from "./exceptions.js"; import { browsersDir, + type CachedVersion, compatFlag, getActivePath, getRepoName, @@ -177,7 +178,7 @@ export class Version { } const [VERSION_MIN, VERSION_MAX] = Version.buildMinMax(); -const SUPPORTED_RANGE = `>=${VERSION_MIN.release}, <${VERSION_MAX.release}`; +export const SUPPORTED_RANGE = `>=${VERSION_MIN.release}, <${VERSION_MAX.release}`; export class GitHubDownloader { githubRepo: string; @@ -198,9 +199,9 @@ export class GitHubDownloader { ); } - async getAsset( + async getReleases( { retries }: { retries: number } = { retries: 5 }, - ): Promise { + ): Promise { let attempts = 0; let response: Response | undefined; @@ -222,9 +223,13 @@ export class GitHubDownloader { ); } - const releases = await response.json(); + return response.json(); + } - for (const release of releases) { + async getAsset( + { retries }: { retries: number } = { retries: 5 }, + ): Promise { + for (const release of await this.getReleases({ retries })) { if (release.prerelease || release.draft) continue; for (const asset of release.assets) { const data = this.checkAsset(asset, release); @@ -247,12 +252,22 @@ export class CamoufoxFetcher extends GitHubDownloader { sha256?: string; createdAt?: string; - constructor() { + /** + * @param selected A build from `listAvailableVersions()` to install instead of the pinned one. + */ + constructor(selected?: CachedVersion) { super("daijro/camoufox"); this.arch = CamoufoxFetcher.getPlatformArch(); this.pattern = new RegExp( `camoufox-(.+)-(.+)-${OS_NAME}\\.${this.arch}\\.zip`, ); + if (selected) { + this._version_obj = new Version(selected.build, selected.version); + this._url = selected.url; + this.isPrerelease = selected.is_prerelease; + this.sha256 = selected.sha256 ?? undefined; + this.createdAt = selected.created_at; + } } async init() { @@ -552,8 +567,66 @@ function activeInstallDir(): string { return getActivePath() ?? INSTALL_DIR.toString(); } +let warnedUnpaired = false; + export function installedVerStr(): string { - return Version.fromPath(activeInstallDir()).fullString; + const installDir = activeInstallDir(); + const version = Version.fromPath(installDir); + const pin = loadPin(); + if ( + !warnedUnpaired && + pin && + installDir !== INSTALL_DIR.toString() && + !pinMatches( + pin, + path.basename(path.dirname(installDir)), + version.version ?? "", + version.release, + ) + ) { + warnedUnpaired = true; + console.warn( + `Launching Camoufox v${version.fullString}, which was selected explicitly. This release of camoufox-js is tested with v${pinSpec(pin)}, run \`camoufox set --release\` to go back to it.`, + ); + } + return version.fullString; +} + +/** + * Every build of the official repo published for this platform, newest first. Prereleases + * and builds this library doesn't support are included, as the Python library shares the cache. + */ +export async function listAvailableVersions(): Promise { + const fetcher = new CamoufoxFetcher(); + const versions: CachedVersion[] = []; + for (const release of await fetcher.getReleases()) { + if (release.draft) continue; + for (const asset of release.assets) { + const match = asset.name.match(fetcher.pattern); + if (!match) continue; + versions.push({ + version: match[1], + build: match[2], + url: asset.browser_download_url, + is_prerelease: + Boolean(release.prerelease) || + match[2].split(".")[0].toLowerCase() === "alpha", + asset_id: asset.id, + asset_size: asset.size, + asset_updated_at: asset.updated_at, + sha256: asset.digest?.startsWith("sha256:") + ? asset.digest.slice("sha256:".length) + : null, + created_at: asset.created_at, + }); + } + } + return versions.sort((a, b) => { + const [va, vb] = [new Version(a.build), new Version(b.build)]; + if (va.lessThan(vb)) return 1; + if (vb.lessThan(va)) return -1; + return (b.created_at ?? "").localeCompare(a.created_at ?? ""); + }); } /** diff --git a/test/multiversion.test.ts b/test/multiversion.test.ts new file mode 100644 index 0000000..58622fd --- /dev/null +++ b/test/multiversion.test.ts @@ -0,0 +1,250 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { CachedVersion } from "../src/multiversion"; + +let installDir: string; + +beforeEach(() => { + installDir = fs.mkdtempSync(path.join(os.tmpdir(), "cfx-mvtest-")); + vi.stubEnv("CAMOUFOX_INSTALL_DIR", installDir); + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.resetModules(); + fs.rmSync(installDir, { recursive: true, force: true }); +}); + +const cached = ( + versionBuild: string, + extra: Partial = {}, +): CachedVersion => { + const [version, build] = versionBuild.split(/-(.*)/); + return { + version, + build, + url: `https://example.com/${versionBuild}`, + is_prerelease: false, + ...extra, + }; +}; + +// Newest first, as listAvailableVersions() returns them. +const VERSIONS = [ + cached("156.0.1-beta.34"), + cached("152.0.4-beta.31", { sha256: "b".repeat(64), created_at: "2" }), + cached("152.0.4-beta.31", { sha256: "a".repeat(64), created_at: "1" }), + cached("152.0.4-beta.30", { sha256: "c".repeat(64) }), + cached("152.0.4-beta.26", { is_prerelease: true }), +]; + +const writeConfig = (config: Record) => + fs.writeFileSync( + path.join(installDir, "config.json"), + JSON.stringify(config), + ); + +function writeInstall(relativePath: string, data: Record) { + const dir = path.join(installDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "version.json"), JSON.stringify(data)); + return dir; +} + +describe("parseSpecifier", () => { + test.each([ + ["152.0.4-beta.30", { spec: "152.0.4-beta.30" }], + ["v152.0.4-beta.30", { spec: "152.0.4-beta.30" }], + ["official/152.0.4-beta.30", { spec: "152.0.4-beta.30" }], + ["official/stable", { channel: "stable" }], + ["Official/Prerelease", { channel: "prerelease" }], + [ + "official/stable/152.0.4-beta.30-5720d45b", + { channel: "stable", spec: "152.0.4-beta.30-5720d45b" }, + ], + ])("parses %s", async (specifier, expected) => { + const { parseSpecifier } = await import("../src/multiversion"); + expect(parseSpecifier(specifier)).toEqual(expected); + }); + + test.each([ + "coryking/stable", + "official/nightly/152.0.4-beta.30", + "a/b/c/d", + ])("rejects %s", async (specifier) => { + const { parseSpecifier } = await import("../src/multiversion"); + expect(() => parseSpecifier(specifier)).toThrow(); + }); +}); + +describe("resolveSpec", () => { + test("picks the newest asset of a build, or the one named by its sha8", async () => { + const { resolveSpec } = await import("../src/multiversion"); + expect(resolveSpec(VERSIONS, "152.0.4-beta.31")?.entry.sha256).toBe( + "b".repeat(64), + ); + expect(resolveSpec(VERSIONS, "152.0.4-beta.31-aaaaaaaa")).toEqual({ + entry: VERSIONS[2], + sha256: "a".repeat(64), + }); + expect(resolveSpec(VERSIONS, "152.0.4-beta.99")).toBeNull(); + }); +}); + +describe("selectBuild", () => { + test("selects the pinned build by default", async () => { + const { selectBuild } = await import("../src/multiversion"); + const pin = (await import("../src/browser-pin")).loadPin(); + const selected = selectBuild(VERSIONS); + expect(`${selected.version}-${selected.build}`).toBe( + `${pin?.version}-${pin?.build}`, + ); + }); + + test("follows the newest supported build of a chosen channel", async () => { + writeConfig({ channel: "official/stable" }); + const { selectBuild } = await import("../src/multiversion"); + expect(selectBuild(VERSIONS)).toBe(VERSIONS[1]); + }); + + test("installs the exact asset a chosen build was pinned to", async () => { + writeConfig({ + channel: "official/stable", + pinned: "152.0.4-beta.31", + pinned_sha: "a".repeat(64), + }); + const { selectBuild } = await import("../src/multiversion"); + expect(selectBuild(VERSIONS)).toBe(VERSIONS[2]); + }); + + test("rejects unsupported and unknown builds", async () => { + const { selectBuild } = await import("../src/multiversion"); + const { UnsupportedVersion } = await import("../src/exceptions"); + expect(() => selectBuild(VERSIONS, "156.0.1-beta.34")).toThrow( + UnsupportedVersion, + ); + expect(() => selectBuild(VERSIONS, "152.0.4-beta.99")).toThrow( + "was not found", + ); + }); +}); + +describe("repo cache", () => { + test("keeps what the Python library synced for other repos", async () => { + fs.writeFileSync( + path.join(installDir, "repo_cache.json"), + JSON.stringify({ + spoof_os: null, + repos: [ + { name: "Official", repo: "daijro/camoufox", versions: [] }, + { name: "CoryKing", repo: "coryking/camoufox", versions: [] }, + ], + }), + ); + const { loadCachedVersions, saveCachedVersions } = await import( + "../src/multiversion" + ); + saveCachedVersions(VERSIONS); + expect(loadCachedVersions()).toEqual(VERSIONS); + const cache = JSON.parse( + fs.readFileSync(path.join(installDir, "repo_cache.json"), "utf-8"), + ); + expect(cache.spoof_os).toBeNull(); + expect(cache.repos.map((r: { name: string }) => r.name)).toEqual([ + "Official", + "CoryKing", + ]); + }); +}); + +describe("findInstalled and removeVersion", () => { + test("removes one build and moves the active one", async () => { + writeInstall("browsers/official/152.0.4-beta.30-5720d45b", { + version: "152.0.4", + build: "beta.30", + }); + const beta31 = writeInstall("browsers/official/152.0.4-beta.31", { + version: "152.0.4", + build: "beta.31", + }); + writeConfig({ active_version: "browsers/official/152.0.4-beta.31" }); + const { findInstalled, loadConfig, removeVersion } = await import( + "../src/multiversion" + ); + const target = findInstalled("official/stable/152.0.4-beta.31"); + expect(target?.path).toBe(beta31); + expect(findInstalled("beta.30")?.version.release).toBe("beta.30"); + expect(findInstalled("official/stable")?.path).toBe(beta31); + + if (!target) throw new Error("beta.31 must be found"); + removeVersion(target); + expect(fs.existsSync(beta31)).toBe(false); + expect(loadConfig().active_version).toBe( + "browsers/official/152.0.4-beta.30-5720d45b", + ); + }); +}); + +describe("listAvailableVersions", () => { + test("lists every build for this platform, newest first", async () => { + const { CamoufoxFetcher, listAvailableVersions, OS_NAME } = await import( + "../src/pkgman" + ); + const suffix = `${OS_NAME}.${CamoufoxFetcher.getPlatformArch()}.zip`; + const asset = (versionBuild: string, extra = {}) => ({ + name: `camoufox-${versionBuild}-${suffix}`, + browser_download_url: `https://example.com/${versionBuild}`, + ...extra, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => [ + { draft: true, assets: [asset("156.0.1-beta.99")] }, + { prerelease: true, assets: [asset("156.0.1-beta.35")] }, + { + assets: [ + asset("152.0.4-beta.30", { digest: `sha256:${"c".repeat(64)}` }), + { name: "camoufox-152.0.4-beta.30-other.zip" }, + ], + }, + { assets: [asset("152.0.4-alpha.25")] }, + ], + })), + ); + const versions = await listAvailableVersions(); + expect( + versions.map((v) => [v.version, v.build, v.is_prerelease, v.sha256]), + ).toEqual([ + ["156.0.1", "beta.35", true, null], + ["152.0.4", "beta.30", false, "c".repeat(64)], + ["152.0.4", "alpha.25", true, null], + ]); + }); +}); + +describe("installedVerStr", () => { + test("warns once when launching an explicitly chosen build", async () => { + writeInstall("browsers/official/152.0.4-beta.31", { + version: "152.0.4", + build: "beta.31", + }); + writeConfig({ + channel: "official/stable", + pinned: "152.0.4-beta.31", + active_version: "browsers/official/152.0.4-beta.31", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { installedVerStr } = await import("../src/pkgman"); + expect(installedVerStr()).toBe("152.0.4-beta.31"); + installedVerStr(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("camoufox set --release"); + }); +});