Add import template into tui. Fix tests that fail on macos. Fix some updates.

This commit is contained in:
2026-05-29 18:16:00 +02:00
parent 85746c3306
commit 2f8dad7d8d
20 changed files with 1748 additions and 46 deletions

View File

@@ -3,6 +3,7 @@ import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -110,7 +111,13 @@ describe("mnemonic utilities", () => {
"/nonexistent",
"mnemonic-relative",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative"));
// Compare to the expected path
expect(resolved).toBe(expectedPath);
} finally {
process.chdir(originalCwd);
}

View File

@@ -1,3 +1,6 @@
// Node js tool for temp dir
import { tmpdir } from "node:os";
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
import {
@@ -16,6 +19,7 @@ import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates";
import { SettingsService } from "../../../src/services/settings";
export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
@@ -67,8 +71,6 @@ export const addFakeResource = async (
status: UnspentOutputStatus.CONFIRMED,
selectable: true,
privacy: false,
templateIdentifier: options.templateIdentifier ?? "test-template",
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
outpointIndex: options.outpointIndex ?? 0,
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000,
@@ -143,10 +145,7 @@ export const createMockEngine = async (seed: string) => {
// Create the in-memory blockchain provider.
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-tests",
electrumOptions: {},
});
await blockchainProvider.initialize();
// Create the blockchain monitor instance.
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
@@ -160,10 +159,13 @@ export const createMockEngine = async (seed: string) => {
};
export const createMockAppService = async (engine: Engine) => {
const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`);
settings.setCurrency("USD");
const storage = await InMemoryStorage.create();
const mockRates = new MockRatesService();
const rates = new RatesService(mockRates);
const rates = new RatesService(mockRates, settings);
const mockElectrum = new MockElectrumService();
@@ -176,5 +178,5 @@ export const createMockAppService = async (engine: Engine) => {
invitationStoragePath: "test-invitations.db",
};
return new AppService(engine, storage, config, mockElectrum, rates);
return new AppService(engine, storage, config, mockElectrum, rates, settings);
};

View File

@@ -1,5 +1,5 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
@@ -93,7 +93,13 @@ describe("paths utilities", () => {
try {
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test"));
// Compare to the expected path
expect(resolved).toBe(expectedPath);
} finally {
process.chdir(originalCwd);
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import {
formatDialogMessageLines,
getMessageContentWidth,
getMessageDialogWidth,
} from "../../src/tui/utils/format-dialog-message.js";
describe("formatDialogMessageLines", () => {
test("drops empty lines from leading newlines", () => {
const lines = formatDialogMessageLines("\n- first\n- second", 80);
expect(lines).toEqual(["- first", "- second"]);
});
test("keeps short lines unchanged", () => {
const lines = formatDialogMessageLines("- actions.receive: Invalid", 80);
expect(lines).toEqual(["- actions.receive: Invalid"]);
});
test("breaks long dot-separated paths at segment boundaries", () => {
const line =
"- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\"";
const lines = formatDialogMessageLines(line, 56);
expect(lines.length).toBeGreaterThan(1);
expect(lines.join("\n")).toContain("actions.requestFungibleTokens.");
expect(lines.every((entry) => entry.length <= 58)).toBe(true);
expect(lines[1]?.startsWith(" ")).toBe(true);
});
});
describe("dialog width helpers", () => {
test("getMessageDialogWidth respects terminal bounds", () => {
expect(getMessageDialogWidth(120)).toBe(100);
expect(getMessageDialogWidth(80)).toBe(76);
expect(getMessageDialogWidth(40)).toBe(60);
});
test("getMessageContentWidth subtracts border and padding", () => {
expect(getMessageContentWidth(76)).toBe(70);
});
});

View File

@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { listDirectoryEntries } from "../../src/tui/utils/list-directory-entries.js";
describe("listDirectoryEntries", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), "xo-file-picker-"));
});
afterEach(() => {
if (existsSync(tempRoot)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
test("includes parent entry and sorts directories before files", () => {
mkdirSync(path.join(tempRoot, "beta-dir"));
mkdirSync(path.join(tempRoot, "alpha-dir"));
writeFileSync(path.join(tempRoot, "zebra.json"), "{}");
writeFileSync(path.join(tempRoot, "apple.txt"), "x");
const childDir = path.join(tempRoot, "child");
mkdirSync(childDir);
writeFileSync(path.join(childDir, "nested.json"), "{}");
const rootResult = listDirectoryEntries(tempRoot);
expect(rootResult.error).toBeUndefined();
expect(rootResult.entries.map((entry) => entry.name)).toEqual([
"..",
"alpha-dir",
"beta-dir",
"child",
"apple.txt",
"zebra.json",
]);
const childResult = listDirectoryEntries(childDir);
expect(childResult.entries[0]).toMatchObject({
name: "..",
kind: "parent",
absolutePath: tempRoot,
});
expect(childResult.entries.slice(1).map((entry) => entry.name)).toEqual([
"nested.json",
]);
});
test("filters files by extension when extensions are provided", () => {
writeFileSync(path.join(tempRoot, "template.json"), "{}");
writeFileSync(path.join(tempRoot, "readme.md"), "# hi");
writeFileSync(path.join(tempRoot, "UPPER.JSON"), "{}");
const result = listDirectoryEntries(tempRoot, { extensions: ["json"] });
expect(result.error).toBeUndefined();
expect(result.entries.map((entry) => entry.name)).toEqual([
"..",
"template.json",
"UPPER.JSON",
]);
});
test("shows all files when extensions are omitted", () => {
writeFileSync(path.join(tempRoot, "a.json"), "{}");
writeFileSync(path.join(tempRoot, "b.txt"), "x");
const result = listDirectoryEntries(tempRoot);
expect(result.entries.map((entry) => entry.name)).toEqual([
"..",
"a.json",
"b.txt",
]);
});
test("omits parent entry at filesystem root", () => {
const rootResult = listDirectoryEntries(path.parse(tempRoot).root);
expect(rootResult.error).toBeUndefined();
expect(rootResult.entries.some((entry) => entry.kind === "parent")).toBe(
false,
);
});
test("returns error for missing directory without throwing", () => {
const missingPath = path.join(tempRoot, "does-not-exist");
const result = listDirectoryEntries(missingPath);
expect(result.entries).toEqual([]);
expect(result.error).toContain("Directory does not exist");
});
test("returns error when path is a file", () => {
const filePath = path.join(tempRoot, "file.txt");
writeFileSync(filePath, "hello");
const result = listDirectoryEntries(filePath);
expect(result.entries).toEqual([]);
expect(result.error).toContain("Not a directory");
});
});

View File

@@ -0,0 +1,78 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parseTemplate } from "@xo-cash/utils";
import {
loadTemplateFromFile,
TemplateLoadError,
} from "../../src/utils/load-template-from-file.js";
import { p2pkhTemplate } from "../cli/mocks/template-p2pkh.js";
describe("loadTemplateFromFile", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), "xo-load-template-"));
});
afterEach(() => {
if (existsSync(tempRoot)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
test("loads JSON templates directly", async () => {
const jsonPath = path.join(tempRoot, "template.json");
writeFileSync(jsonPath, JSON.stringify(p2pkhTemplate));
const contents = await loadTemplateFromFile(jsonPath);
const parsed = parseTemplate(contents);
expect(parsed.name).toBe(p2pkhTemplate.name);
});
test("loads TypeScript templates via child process", async () => {
const tsTemplatePath = path.resolve(
process.cwd(),
"../templates/source/p2pkh.ts",
);
expect(existsSync(tsTemplatePath)).toBe(true);
const contents = await loadTemplateFromFile(tsTemplatePath);
const parsed = parseTemplate(contents);
expect(parsed.name).toBe("Wallet (P2PKH)");
});
test("loads JavaScript templates via child process", async () => {
const jsPath = path.join(tempRoot, "template.mjs");
writeFileSync(
jsPath,
`export default ${JSON.stringify(p2pkhTemplate)};\n`,
"utf8",
);
const contents = await loadTemplateFromFile(jsPath);
const parsed = parseTemplate(contents);
expect(parsed.name).toBe(p2pkhTemplate.name);
});
test("throws TemplateLoadError for missing files", async () => {
await expect(
loadTemplateFromFile(path.join(tempRoot, "missing.json")),
).rejects.toBeInstanceOf(TemplateLoadError);
});
test("throws TemplateLoadError for unsupported extensions", async () => {
const txtPath = path.join(tempRoot, "template.txt");
writeFileSync(txtPath, "hello");
await expect(loadTemplateFromFile(txtPath)).rejects.toThrow(
/Unsupported template file extension/,
);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import {
isTemplateLike,
pickTemplateExport,
} from "../../src/utils/pick-template-export.js";
const sampleTemplate = {
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
name: "Sample",
roles: { owner: { name: "Owner" } },
};
describe("pickTemplateExport", () => {
test("isTemplateLike accepts objects with schema, name, and roles", () => {
expect(isTemplateLike(sampleTemplate)).toBe(true);
expect(isTemplateLike(null)).toBe(false);
expect(isTemplateLike({ name: "Missing schema" })).toBe(false);
});
test("prefers default export when template-like", () => {
const picked = pickTemplateExport({
default: sampleTemplate,
otherTemplate: {
...sampleTemplate,
name: "Other",
},
});
expect(picked).toBe(sampleTemplate);
});
test("uses a single named export when no default export exists", () => {
const picked = pickTemplateExport({
p2pkhTemplate: sampleTemplate,
});
expect(picked).toBe(sampleTemplate);
});
test("throws when multiple template exports exist", () => {
expect(() =>
pickTemplateExport({
firstTemplate: sampleTemplate,
secondTemplate: { ...sampleTemplate, name: "Second" },
}),
).toThrow(/Multiple template exports found/);
});
test("throws when no template export exists", () => {
expect(() =>
pickTemplateExport({
notATemplate: { foo: "bar" },
}),
).toThrow(/No XOTemplate export found/);
});
});