Files
hyperframes/packages/cli/src/registry/compatibility.test.ts
T
8eac7e1cda fix(cli): resolve and install transitive registry dependencies (#1396)
* fix(cli): resolve and install transitive registry dependencies

`hyperframes add`, `hyperframes new` (fetchRemoteTemplate), and the studio
"add block" path each resolved a single registry item and silently dropped
any `registryDependencies` it declared.

Add `resolveItemWithDependencies` (DFS topological sort, cycle detection,
missing-dependency errors, and dedup of shared/diamond deps) and route all
three install paths through it so dependencies are installed before the item
that needs them. `resolveItem` becomes a thin guard that throws on dep-bearing
items, so no future caller can silently reintroduce the drop. `runAdd` now
returns the ordered `installed` list and compatibility-gates every dependency
before any write.

Reworks the stale PR #414 onto current main and addresses its review feedback:
fetchRemoteTemplate installs deps, no out-of-scope files, dead null-checks
dropped, diamond test added, and the deliberate serial-fetch tradeoff is noted.

Co-authored-by: Rakibul Islam <40rakib70@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): make getItem async so missing-dep surfaces as rejection

Addresses review nit on #1396: getItem was typed Promise<RegistryItem> but
threw synchronously on a missing dependency. Marking it async keeps the
control flow consistent with the return type — the throw now becomes a
rejection. The body has no await, so the item cache is still populated
synchronously on first request and dedup is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): compatibility-gate transitive deps in all install paths

Addresses Via's review on #1396: `assertCompatibleOrThrow` only ran inside
`runAdd`, so `fetchRemoteTemplate` (hyperframes new) and the Studio
"add block" action installed resolved items — now including transitive
dependencies — with no minCliVersion enforcement or deprecation warnings. A
pre-existing single-item asymmetry that this PR's dep loops amplify across N
items.

- Add shared `gateRegistryItemsCompatibility` + `RegistryCompatibilityError`
  to compatibility.ts; all three install paths now gate the full resolved set
  before any write. `runAdd` keeps its AddError mapping by wrapping the shared
  gate.
- Surface deprecation warnings from the template/studio paths to stderr.
- Extract the studio viewport rewrite into `rewriteWrittenToHostViewport`
  (also drops redundant dynamic node:fs imports) and document that it
  intentionally rewrites dep-shipped .html too (Via item 3).
- Unit-test the shared gate directly (no fetch/cache flakiness): compatible
  set, accumulated deprecation warnings, and throw-on-incompatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Rakibul Islam <40rakib70@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:18:15 -07:00

97 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { RegistryItem } from "@hyperframes/core";
import {
checkRegistryItemCompatibility,
gateRegistryItemsCompatibility,
RegistryCompatibilityError,
} from "./compatibility.js";
const BASE_ITEM: RegistryItem = {
name: "demo-block",
type: "hyperframes:block",
title: "Demo Block",
description: "Block for tests",
dimensions: { width: 1080, height: 1350 },
duration: 6,
files: [
{
path: "demo-block.html",
target: "compositions/demo-block.html",
type: "hyperframes:composition",
},
],
};
describe("checkRegistryItemCompatibility", () => {
it("returns no warning or error for compatible items", () => {
expect(checkRegistryItemCompatibility(BASE_ITEM, "0.6.79")).toEqual({ warnings: [] });
});
it("returns a warning for deprecated items", () => {
const result = checkRegistryItemCompatibility(
{ ...BASE_ITEM, deprecated: "Use `demo-block-v2` instead." },
"0.6.79",
);
expect(result).toEqual({
warnings: ['Registry item "demo-block" is deprecated: Use `demo-block-v2` instead.'],
});
});
it("returns an error when the current CLI is below minCliVersion", () => {
const result = checkRegistryItemCompatibility(
{ ...BASE_ITEM, minCliVersion: "0.6.80" },
"0.6.79",
);
expect(result.error).toContain('Registry item "demo-block" requires hyperframes >= 0.6.80');
});
it("allows source/dev CLI builds to install future-gated registry items", () => {
expect(
checkRegistryItemCompatibility({ ...BASE_ITEM, minCliVersion: "999.0.0" }, "0.0.0-dev"),
).toEqual({ warnings: [] });
});
it("returns a clear error for malformed minCliVersion metadata", () => {
const result = checkRegistryItemCompatibility(
{ ...BASE_ITEM, minCliVersion: "next" },
"0.6.79",
);
expect(result.error).toContain('declares invalid minCliVersion "next"');
});
});
describe("gateRegistryItemsCompatibility", () => {
const dep = (
name: string,
extra: { deprecated?: string; minCliVersion?: string } = {},
): RegistryItem => ({
...BASE_ITEM,
name,
...extra,
});
it("returns no warnings when every item is compatible", () => {
expect(gateRegistryItemsCompatibility([dep("a"), dep("b")], "0.6.79")).toEqual([]);
});
it("accumulates deprecation warnings across all items", () => {
const warnings = gateRegistryItemsCompatibility(
[dep("a", { deprecated: "gone" }), dep("b"), dep("c", { deprecated: "also gone" })],
"0.6.79",
);
expect(warnings).toEqual([
'Registry item "a" is deprecated: gone',
'Registry item "c" is deprecated: also gone',
]);
});
it("throws RegistryCompatibilityError on the first item requiring a newer CLI", () => {
expect(() =>
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
).toThrow(RegistryCompatibilityError);
expect(() =>
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
).toThrow(/requires hyperframes >= 999\.0\.0/);
});
});