Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/shell/check-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ vi.mock("./run-command.js", () => ({
import { checkBinary } from "./check-binary.js";

describe("checkBinary", () => {
it("skips the Stellar version gate because the real command validates it", async () => {
runCommand.mockResolvedValueOnce({ stdout: "stellar 25.2.0", stderr: "", all: "" });

await checkBinary("stellar", "hint");

expect(runCommand).toHaveBeenCalledWith("stellar", ["--version"], {
skipStellarVersionCheck: true,
});
});

it("should_throw_RUST_NOT_FOUND_when_rustc_is_missing", async () => {
runCommand.mockRejectedValueOnce(new Error("not found"));

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/shell/check-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export async function checkBinary(
options: CheckBinaryOptions = {}
): Promise<void> {
try {
await runCommand(binary, ["--version"], options);
await runCommand(binary, ["--version"], {
...options,
...(binary === "stellar" ? { skipStellarVersionCheck: true } : {}),
});
} catch (error) {
if (error instanceof CaatingaError) {
throw error;
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/shell/resolve-subprocess-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@ import {
describe("resolveSubprocessEnv", () => {
it("should_prepend_cargo_bin_when_it_exists", () => {
const home = os.homedir();
const cargoBin = path.join(home, ".cargo", "bin");
const env = resolveSubprocessEnv({
HOME: home,
PATH: "/usr/bin",
});

expect(env.PATH?.startsWith(cargoBin)).toBe(true);
expect(env.PATH).toContain("/usr/bin");
});

Expand Down
77 changes: 68 additions & 9 deletions packages/core/src/stellar-cli/check-stellar-cli-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,97 @@ vi.mock("../shell/run-command.js", () => ({
runCommand: runCommandMock,
}));

import { checkStellarCliVersion } from "./check-stellar-cli-version.js";
import { parseStellarCliVersion } from "./version.js";

describe("checkStellarCliVersion", () => {
beforeEach(() => {
vi.resetModules();
runCommandMock.mockReset();
});

async function loadCheckStellarCliVersion() {
return (await import("./check-stellar-cli-version.js")).checkStellarCliVersion;
}

it("returns a supported report for the last-tested version", async () => {
runCommandMock.mockResolvedValueOnce({
stdout: "stellar 25.2.0",
stderr: "",
all: "stellar 25.2.0",
});
const checkStellarCliVersion = await loadCheckStellarCliVersion();

const report = await checkStellarCliVersion();

expect(report.status).toBe("supported");
expect(report.version).toBe("25.2.0");
expect(report.warnings).toEqual([]);
expect(runCommandMock).toHaveBeenCalledWith("stellar", ["--version"], {
cwd: process.cwd(),
skipStellarVersionCheck: true,
});
});

it("reuses validation for repeated calls in the same context", async () => {
runCommandMock.mockResolvedValue({
stdout: "stellar 25.2.0",
stderr: "",
all: "stellar 25.2.0",
});
const checkStellarCliVersion = await loadCheckStellarCliVersion();

await checkStellarCliVersion({ probeFeatures: false });
await checkStellarCliVersion({ probeFeatures: false });

expect(runCommandMock).toHaveBeenCalledTimes(1);
});

it("does not share validation between working directories", async () => {
const originalCwd = process.cwd;
let cwd = "/project-a";
process.cwd = () => cwd;
runCommandMock.mockResolvedValue({
stdout: "stellar 25.2.0",
stderr: "",
all: "stellar 25.2.0",
});
const checkStellarCliVersion = await loadCheckStellarCliVersion();

try {
await checkStellarCliVersion({ probeFeatures: false });
cwd = "/project-b";
await checkStellarCliVersion({ probeFeatures: false });
} finally {
process.cwd = originalCwd;
}

expect(runCommandMock).toHaveBeenCalledTimes(2);
});

it("evicts failed validation so a later call can retry", async () => {
runCommandMock
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce({ stdout: "stellar 25.2.0", stderr: "", all: "stellar 25.2.0" });
const checkStellarCliVersion = await loadCheckStellarCliVersion();

await expect(checkStellarCliVersion({ probeFeatures: false })).rejects.toThrow(
"temporary failure"
);
await expect(checkStellarCliVersion({ probeFeatures: false })).resolves.toMatchObject({
version: "25.2.0",
});
expect(runCommandMock).toHaveBeenCalledTimes(2);
});

it("emits a warning via the onWarning hook for newer-than-tested versions", async () => {
runCommandMock.mockResolvedValueOnce({
stdout: "stellar 99.0.0",
stderr: "",
all: "stellar 99.0.0",
});

const checkStellarCliVersion = await loadCheckStellarCliVersion();
const onWarning = vi.fn();

const report = await checkStellarCliVersion({ onWarning });

expect(report.status).toBe("untested");
Expand All @@ -55,15 +113,16 @@ describe("checkStellarCliVersion", () => {
stderr: "",
all: "stellar 28.0.0",
});

const checkStellarCliVersion = await loadCheckStellarCliVersion();
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);

try {
const report = await checkStellarCliVersion();
expect(report.status).toBe("untested");
expect(stderrSpy).toHaveBeenCalled();
const payload = stderrSpy.mock.calls.map((call) => call[0]).join("\n");
expect(payload).toContain("Stellar CLI 28.0.0");
expect(stderrSpy.mock.calls.map((call) => call[0]).join("\n")).toContain(
"Stellar CLI 28.0.0"
);
} finally {
stderrSpy.mockRestore();
}
Expand All @@ -75,6 +134,7 @@ describe("checkStellarCliVersion", () => {
stderr: "",
all: "stellar 22.0.1",
});
const checkStellarCliVersion = await loadCheckStellarCliVersion();

await expect(checkStellarCliVersion()).rejects.toMatchObject({
code: CaatingaErrorCode.UNSUPPORTED_CLI_VERSION,
Expand All @@ -84,6 +144,7 @@ describe("checkStellarCliVersion", () => {

it("normalizes missing stellar binary to CAATINGA_STELLAR_CLI_NOT_FOUND", async () => {
runCommandMock.mockRejectedValueOnce(Object.assign(new Error("not found"), { code: "ENOENT" }));
const checkStellarCliVersion = await loadCheckStellarCliVersion();

await expect(checkStellarCliVersion()).rejects.toMatchObject({
code: CaatingaErrorCode.STELLAR_CLI_NOT_FOUND,
Expand All @@ -96,13 +157,11 @@ describe("checkStellarCliVersion", () => {
stderr: "",
all: "stellar dev build",
});
const checkStellarCliVersion = await loadCheckStellarCliVersion();

expect(() => parseStellarCliVersion("stellar dev build")).toThrowError(
expect.objectContaining({
code: CaatingaErrorCode.STELLAR_CLI_VERSION_PARSE_FAILED,
})
expect.objectContaining({ code: CaatingaErrorCode.STELLAR_CLI_VERSION_PARSE_FAILED })
);

await expect(checkStellarCliVersion()).rejects.toMatchObject({
code: CaatingaErrorCode.STELLAR_CLI_VERSION_PARSE_FAILED,
});
Expand Down
66 changes: 52 additions & 14 deletions packages/core/src/stellar-cli/check-stellar-cli-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,63 @@ export type CheckStellarCliVersionOptions = {
probeFeatures?: boolean;
};

type ValidationContext = {
cwd: string;
features?: readonly string[];
lastTestedVersion?: string;
};

const validationCache = new Map<string, Promise<CompatibilityReport>>();

export async function checkStellarCliVersion(
input: CheckStellarCliVersionOptions = {}
): Promise<CompatibilityReport> {
const context: ValidationContext = {
cwd: process.cwd(),
features: input.features,
lastTestedVersion: input.lastTestedVersion,
};
const cacheKey = JSON.stringify({
cwd: context.cwd,
features: context.features ?? [],
lastTestedVersion: context.lastTestedVersion,
probeFeatures: input.probeFeatures !== false,
});

let validation = validationCache.get(cacheKey);
if (!validation) {
validation = validateStellarCli(context, input.probeFeatures !== false);
validationCache.set(cacheKey, validation);
validation.catch(() => {
if (validationCache.get(cacheKey) === validation) {
validationCache.delete(cacheKey);
}
});
}

const report = await validation;
for (const warning of report.warnings) {
if (input.onWarning) {
input.onWarning(warning);
} else {
defaultEmitWarning(warning);
}
}
return report;
}

async function validateStellarCli(
input: ValidationContext,
probeFeatures: boolean
): Promise<CompatibilityReport> {
let rawOutput: string;

try {
const result = await runCommand("stellar", ["--version"], {
cwd: input.cwd,
skipStellarVersionCheck: true,
});
rawOutput = result.all || result.stdout || result.stderr;
rawOutput = result?.all || result?.stdout || result?.stderr || "";
} catch (error) {
if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") {
throw new CaatingaError(
Expand All @@ -40,25 +87,16 @@ export async function checkStellarCliVersion(
}

const version = parseStellarCliVersion(rawOutput);
const probedMissing =
input.probeFeatures === false ? [] : await probeMissingStellarCliFeatures(version);
const probedMissing = probeFeatures
? await probeMissingStellarCliFeatures(version, input.cwd)
: [];
const missingFeatures = [...(input.features ?? []), ...probedMissing];

const report = evaluateStellarCliCompatibility({
return evaluateStellarCliCompatibility({
version,
features: missingFeatures.length > 0 ? missingFeatures : undefined,
lastTestedVersion: input.lastTestedVersion,
});

for (const warning of report.warnings) {
if (input.onWarning) {
input.onWarning(warning);
} else {
defaultEmitWarning(warning);
}
}

return report;
}

function defaultEmitWarning(warning: CompatibilityWarning): void {
Expand Down
82 changes: 48 additions & 34 deletions packages/core/src/stellar-cli/probe-stellar-cli-features.test.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,57 @@
import { describe, expect, it, beforeAll } from "vitest";
import { checkBinary } from "../shell/check-binary.js";
import { checkStellarCliVersion } from "./check-stellar-cli-version.js";
import {
probeMissingStellarCliFeatures,
STELLAR_CLI_REQUIRED_FEATURES,
} from "./probe-stellar-cli-features.js";
import { parseStellarCliVersion } from "./version.js";

describe("probeMissingStellarCliFeatures (live Stellar CLI)", () => {
let stellarAvailable = false;

beforeAll(async () => {
try {
await checkBinary("stellar", "missing");
stellarAvailable = true;
} catch {
stellarAvailable = false;
}
import { beforeEach, describe, expect, it, vi } from "vitest";

const runCommandMock = vi.hoisted(() => vi.fn());

vi.mock("../shell/run-command.js", () => ({
runCommand: runCommandMock,
}));

import { STELLAR_CLI_REQUIRED_FEATURES } from "./probe-stellar-cli-features.js";

describe("probeMissingStellarCliFeatures", () => {
beforeEach(() => {
vi.resetModules();
runCommandMock.mockReset();
});

async function loadProbe() {
return (await import("./probe-stellar-cli-features.js")).probeMissingStellarCliFeatures;
}

it("returns the missing feature ids", async () => {
runCommandMock
.mockResolvedValueOnce({ stdout: "ok", stderr: "", all: "ok" })
.mockRejectedValueOnce(new Error("missing"))
.mockResolvedValueOnce({ stdout: "ok", stderr: "", all: "ok" });
const probe = await loadProbe();

await expect(probe("25.2.0", "/project")).resolves.toEqual(["contract-deploy"]);
});

it("reuses feature probes for the same version and working directory", async () => {
runCommandMock.mockResolvedValue({ stdout: "ok", stderr: "", all: "ok" });
const probe = await loadProbe();

await probe("25.2.0", "/project");
await probe("25.2.0", "/project");

expect(runCommandMock).toHaveBeenCalledTimes(STELLAR_CLI_REQUIRED_FEATURES.length);
});

it("should_report_no_missing_features_for_installed_cli", async () => {
if (!stellarAvailable) {
return;
}
it("does not share feature probes between working directories", async () => {
runCommandMock.mockResolvedValue({ stdout: "ok", stderr: "", all: "ok" });
const probe = await loadProbe();

const result = await checkStellarCliVersion({ probeFeatures: true });
const missing = await probeMissingStellarCliFeatures(result.version);
await probe("25.2.0", "/project-a");
await probe("25.2.0", "/project-b");

expect(STELLAR_CLI_REQUIRED_FEATURES.every((feature) => !missing.includes(feature))).toBe(true);
expect(result.warnings.filter((w) => w.code === "STELLAR_CLI_MISSING_FEATURE")).toEqual([]);
expect(runCommandMock).toHaveBeenCalledTimes(STELLAR_CLI_REQUIRED_FEATURES.length * 2);
});

it("should_parse_version_from_stellar_binary", async () => {
if (!stellarAvailable) {
return;
}
it("does not probe features below the minimum version", async () => {
const probe = await loadProbe();

const report = await checkStellarCliVersion({ probeFeatures: false });
expect(report.version).toMatch(/^\d+\.\d+\.\d+/);
expect(parseStellarCliVersion(`stellar ${report.version}`)).toBe(report.version);
await expect(probe("22.0.1", "/project")).resolves.toEqual(["contract-invoke-sign"]);
expect(runCommandMock).not.toHaveBeenCalled();
});
});
Loading