import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import fs from "fs"; import os from "os"; import path from "path"; import { Readable, Writable } from "stream"; import Remote from "../src/remote"; class MemoryWebdavClient { constructor() { this.files = new Map(); this.dirs = new Set(["/"]); } normalize(remotePath) { const normalized = path.posix.normalize("/" + String(remotePath || "").replace(/\\/g, "/")); return normalized === "/" ? "/" : normalized.replace(/\/$/, ""); } async exists(remotePath) { const key = this.normalize(remotePath); return this.dirs.has(key) || this.files.has(key); } async createDirectory(remotePath) { const key = this.normalize(remotePath); const parts = key.split("/").filter(Boolean); let current = ""; for (const part of parts) { current += "/" + part; this.dirs.add(current); } } async getDirectoryContents(remotePath) { const dir = this.normalize(remotePath); const prefix = dir === "/" ? "/" : dir + "/"; const names = new Map(); for (const item of [...this.dirs, ...this.files.keys()]) { if (item === dir || !item.startsWith(prefix)) { continue; } const rest = item.slice(prefix.length); const name = rest.split("/")[0]; names.set(name, prefix + name); } return [...names].map(([basename, filename]) => ({ basename, filename, type: this.dirs.has(this.normalize(filename)) ? "directory" : "file" })); } async putFileContents(remotePath, content) { const key = this.normalize(remotePath); await this.createDirectory(path.posix.dirname(key)); this.files.set(key, Buffer.isBuffer(content) ? content : Buffer.from(String(content))); } async getFileContents(remotePath, options = {}) { const key = this.normalize(remotePath); if (!this.files.has(key)) { throw new Error(`missing ${key}`); } const data = this.files.get(key); return options.format === "text" ? data.toString("utf-8") : data; } createWriteStream(remotePath) { const key = this.normalize(remotePath); const chunks = []; const stream = new Writable({ write(chunk, encoding, callback) { chunks.push(Buffer.from(chunk)); callback(); } }); stream.on("finish", () => { this.createDirectory(path.posix.dirname(key)); this.files.set(key, Buffer.concat(chunks)); }); return stream; } createReadStream(remotePath) { const key = this.normalize(remotePath); if (!this.files.has(key)) { throw new Error(`missing ${key}`); } return Readable.from(this.files.get(key)); } async stat(remotePath) { const key = this.normalize(remotePath); const data = this.files.get(key); if (!data) { throw new Error(`missing ${key}`); } return { size: data.length }; } } function writeFixtureProject(tempDir, version, content, group = "/modules") { const projectDir = path.resolve(tempDir, `fixture-${version || "auto"}-${Math.random().toString(16).slice(2)}`); fs.mkdirSync(projectDir, { recursive: true }); const filePath = path.resolve(projectDir, "myapp.txt"); fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(path.resolve(projectDir, "meta.json"), JSON.stringify({ id: "myapp", group, version, desc: "test app", official_url: "https://example.test/myapp", dist: [ { path: filePath, os: "any", platform: "any", filename: "myapp.txt", type: "data" } ] }, null, 4), "utf-8"); return projectDir; } function createRemote() { const remote = new Remote(); remote.client = new MemoryWebdavClient(); return remote; } let tempDir; let localStore; let oldLogger; let oldConfig; beforeEach(() => { tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), "kit-remote-test-")); localStore = path.resolve(tempDir, "local-store"); oldLogger = globalThis.$logger; oldConfig = globalThis.$sConfig; globalThis.$logger = { info() {}, warn() {}, error() {}, debug() {} }; globalThis.$sConfig = { getLocalStorePath: () => localStore }; }); afterEach(() => { globalThis.$logger = oldLogger; globalThis.$sConfig = oldConfig; fs.rmSync(tempDir, { recursive: true, force: true }); }); describe("remote module public API", () => { test("exposes documented public methods", () => { const remote = createRemote(); expect(typeof remote.check).toBe("function"); expect(typeof remote.publish).toBe("function"); expect(typeof remote.install).toBe("function"); expect(typeof remote.list).toBe("function"); expect(typeof remote.use).toBe("function"); expect(typeof remote.rollback).toBe("function"); expect(typeof remote.remove).toBe("function"); }); test("check validates an available webdav client", async () => { const remote = createRemote(); expect(await remote.check()).toBe(true); }); test("publish stores files under default group /modules/id", async () => { const remote = createRemote(); const projectDir = writeFixtureProject(tempDir, "1.0", "hello v1"); await remote.publish(projectDir); const metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" })); const version = JSON.parse(await remote.client.getFileContents("/modules/myapp/1.0/version.json", { format: "text" })); const artifact = await remote.client.getFileContents("/modules/myapp/1.0/any/any/myapp.txt", { format: "text" }); expect(metadata.project_id).toBe("myapp"); expect(metadata.group).toBe("/modules"); expect(metadata.version).toBe("1.0"); expect(metadata.versions).toBeUndefined(); expect(metadata.latest_version).toBeUndefined(); expect(version.artifacts[0].path).toBe("any/any/myapp.txt"); expect(artifact).toBe("hello v1"); }); test("publish supports custom nested group", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "2.0", "docker net", "/docker/net")); const metadata = JSON.parse(await remote.client.getFileContents("/docker/net/myapp/metadata.json", { format: "text" })); const artifact = await remote.client.getFileContents("/docker/net/myapp/2.0/any/any/myapp.txt", { format: "text" }); expect(metadata.group).toBe("/docker/net"); expect(metadata.version).toBe("2.0"); expect(artifact).toBe("docker net"); }); test("empty group stores project at repository root", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "1.0", "root group", "")); const metadata = JSON.parse(await remote.client.getFileContents("/myapp/metadata.json", { format: "text" })); expect(metadata.group).toBe("/"); expect(await remote.client.getFileContents("/myapp/1.0/any/any/myapp.txt", { format: "text" })).toBe("root group"); }); test("explicit version fails when remote version already exists", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1")); await expect(remote.publish(writeFixtureProject(tempDir, "1.0", "hello again"))).rejects.toThrow("远程版本已存在"); }); test("blank version increments remote version tail or starts at 1.0", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "", "first auto")); let metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" })); expect(metadata.version).toBe("1.0"); await remote.publish(writeFixtureProject(tempDir, "", "second auto")); metadata = JSON.parse(await remote.client.getFileContents("/modules/myapp/metadata.json", { format: "text" })); expect(metadata.version).toBe("1.1"); expect(await remote.client.getFileContents("/modules/myapp/1.1/any/any/myapp.txt", { format: "text" })).toBe("second auto"); }); test("install downloads latest version from default group", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1")); const installed = await remote.install("myapp"); const moduleRoot = path.resolve(localStore, "modules", "myapp"); const installJson = JSON.parse(fs.readFileSync(path.resolve(moduleRoot, ".meta", "install.json"), "utf-8")); const installedFile = fs.readFileSync(path.resolve(moduleRoot, "versions", "1.0", "any", "any", "myapp.txt"), "utf-8"); expect(installed).toBe("1.0"); expect(installedFile).toBe("hello v1"); expect(installJson.current_version).toBe("1.0"); expect(installJson.installed_versions).toContain("1.0"); expect(fs.existsSync(path.resolve(moduleRoot, "current"))).toBe(true); }); test("install can target a custom group path", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "1.0", "docker install", "/docker/net")); const installed = await remote.install("/docker/net/myapp"); const installedFile = fs.readFileSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0", "any", "any", "myapp.txt"), "utf-8"); expect(installed).toBe("1.0"); expect(installedFile).toBe("docker install"); }); test("list use rollback and remove manage installed versions", async () => { const remote = createRemote(); await remote.publish(writeFixtureProject(tempDir, "1.0", "hello v1")); await remote.install("myapp"); await remote.publish(writeFixtureProject(tempDir, "2.0", "hello v2")); await remote.install("myapp"); expect(await remote.list("myapp")).toEqual(["1.0", "2.0"]); await remote.rollback("myapp"); let installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); expect(installJson.current_version).toBe("1.0"); await remote.use("myapp@2.0"); installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); expect(installJson.current_version).toBe("2.0"); await remote.remove("myapp@1.0"); installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); expect(installJson.installed_versions).toEqual(["2.0"]); expect(fs.existsSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0"))).toBe(false); }); });