Files
hyperframes/packages/cli/src/utils/projectConfig.test.ts
T
Miguel Ángel e00baefb5c fix(studio): reload the preview only when a file it loaded changes (#4570)
* fix(studio): reload the preview only when a file it loaded changes

* test(studio): read the owner id from the new change-owner shape

* refactor(studio): drop a comment the parser name already says

* fix(studio): keep reloading until the preview is built, and count what its document names

* style(studio): format the reference pattern and name the kept reload

* fix(studio-server): scan preview references in linear time and refresh all thumbnails on a merged reload

* fix(studio): keep a waiting film change when a file the preview never loaded arrives behind it

* test(studio): name the preview ownership test for what it checks

* chore(ci): allow the watchIgnore removals and trim the new comments

* fix(studio-server): reload the preview when a folder holding a file it loaded moves in or out

* perf(studio-server): check a changed folder in one lookup and cover every reference form

* fix(studio-server): record read folders for a project at the filesystem root

* fix(studio-server): reload the preview when the file a symlinked asset points at changes

* fix(studio-server): resolve symlinks on every preview read and key projects by their real folder

* fix(studio-server): record every link a preview read passes through, resolved as the system does
2026-09-27 10:05:53 +00:00

466 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
normalizeConfig,
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
recordProjectRegistryItems,
seedProjectAuthoringSkill,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
function tmp(): string {
return mkdtempSync(join(tmpdir(), "hf-cfg-test-"));
}
describe("projectConfig", () => {
describe("write + read round-trip", () => {
it("writes the default config and reads it back", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const read = readProjectConfig(dir);
expect(read).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("writes a custom config and reads it back verbatim", () => {
const dir = tmp();
try {
const custom = {
$schema: DEFAULT_PROJECT_CONFIG.$schema,
registry: "https://example.com/my-registry",
paths: { blocks: "src/blocks", components: "src/fx", assets: "media" },
media: { autoProxy: true },
};
writeProjectConfig(dir, custom);
const read = readProjectConfig(dir);
expect(read).toEqual(custom);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("normalizeConfig", () => {
it("fills in defaults for missing fields", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.registry).toBe("https://alt.example.com");
expect(result.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
expect(result.$schema).toBe(DEFAULT_PROJECT_CONFIG.$schema);
});
it("preserves partial paths objects", () => {
const result = normalizeConfig({ paths: { blocks: "x" } as unknown as never });
expect(result.paths.blocks).toBe("x");
expect(result.paths.components).toBe(DEFAULT_PROJECT_CONFIG.paths.components);
expect(result.paths.assets).toBe(DEFAULT_PROJECT_CONFIG.paths.assets);
});
it("defaults media.autoProxy to true when media is absent", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves an explicit media.autoProxy: false", () => {
const result = normalizeConfig({ media: { autoProxy: false } });
expect(result.media).toEqual({ autoProxy: false });
});
it("falls back to the default when media.autoProxy is malformed", () => {
const result = normalizeConfig({
media: { autoProxy: "nope" } as unknown as never,
});
expect(result.media).toEqual({ autoProxy: true });
});
it("falls back to the default when media itself is malformed", () => {
const result = normalizeConfig({ media: "nope" as unknown as never });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves a valid authoringSkill slug", () => {
const result = normalizeConfig({ authoringSkill: "product-launch-video" });
expect(result.authoringSkill).toBe("product-launch-video");
});
it("drops an invalid authoringSkill (never reaches telemetry)", () => {
const result = normalizeConfig({
authoringSkill: "Not A Slug!" as unknown as never,
});
expect(result.authoringSkill).toBeUndefined();
});
});
describe("readProjectConfig", () => {
it("returns undefined when the file is absent", () => {
const dir = tmp();
try {
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns undefined when the file is corrupt", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("normalizes a partial on-disk config", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
const read = readProjectConfig(dir);
expect(read?.registry).toBe("https://only-this.example.com");
expect(read?.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("loadProjectConfig", () => {
it("returns defaults when no config file exists", () => {
const dir = tmp();
try {
expect(loadProjectConfig(dir)).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("writeProjectConfig", () => {
it("writes to hyperframes.json at the project root", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const path = join(dir, PROJECT_CONFIG_FILENAME);
const parsed = JSON.parse(readFileSync(path, "utf-8"));
expect(parsed.registry).toBe(DEFAULT_PROJECT_CONFIG.registry);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("resolveAutoProxy", () => {
it("defaults to true when no config file exists and no flag is passed", () => {
const dir = tmp();
try {
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns false when the config sets media.autoProxy: false", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("defaults to true when the config file is partial and omits media", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit false flag wins over a config that enables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: true } }),
"utf-8",
);
expect(resolveAutoProxy(dir, false)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit true flag wins over a config that disables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, true)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back to the default when the on-disk media value is malformed", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: "nope" } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("seedProjectAuthoringSkill", () => {
it("stamps the owning skill into a fresh project (creates the config)", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(loadProjectConfig(dir).authoringSkill).toBe("faceless-explainer");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("preserves other config fields when stamping an existing config", () => {
const dir = tmp();
try {
writeProjectConfig(dir, {
...DEFAULT_PROJECT_CONFIG,
registry: "https://custom.example.com",
});
seedProjectAuthoringSkill(dir, "pr-to-video");
const read = readProjectConfig(dir);
expect(read?.authoringSkill).toBe("pr-to-video");
expect(read?.registry).toBe("https://custom.example.com");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("is seed-once: a later --skill never overwrites the project owner", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "product-launch-video");
seedProjectAuthoringSkill(dir, "motion-graphics");
expect(loadProjectConfig(dir).authoringSkill).toBe("product-launch-video");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("ignores an invalid slug and writes nothing", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "Not A Slug!");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The seed is the only writer that touches an existing hyperframes.json,
// which is normally committed — a render must not diff it beyond the one
// key being added. Guards against round-tripping through normalizeConfig.
it("preserves config keys outside the known schema", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify(
{
registry: "https://example.com/my-registry",
myTeamSetting: { reviewer: "wenbo", keep: true },
futureSchemaKey: 42,
},
null,
2,
),
"utf-8",
);
seedProjectAuthoringSkill(dir, "product-launch-video");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.authoringSkill).toBe("product-launch-video");
expect(raw.myTeamSetting).toEqual({ reviewer: "wenbo", keep: true });
expect(raw.futureSchemaKey).toBe(42);
expect(raw.registry).toBe("https://example.com/my-registry");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not materialize a media block the user never wrote", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 2),
"utf-8",
);
seedProjectAuthoringSkill(dir, "motion-graphics");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.media).toBeUndefined();
expect(raw.$schema).toBeUndefined();
expect(Object.keys(raw)).toEqual(["registry", "authoringSkill"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reuses the file's own indentation", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 4),
"utf-8",
);
seedProjectAuthoringSkill(dir, "pr-to-video");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toContain('\n "registry"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("leaves a corrupt config untouched rather than clobbering it", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("recordProjectRegistryItems", () => {
const BLOCK = {
name: "data-chart",
type: "hyperframes:block",
target: "compositions/data-chart.html",
};
it("appends installed items to an existing config", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
recordProjectRegistryItems(dir, [BLOCK]);
expect(loadProjectConfig(dir).registryItems).toEqual([BLOCK]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("dedupes by name, so re-adding an item does not grow the manifest", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
recordProjectRegistryItems(dir, [BLOCK]);
recordProjectRegistryItems(dir, [BLOCK, { ...BLOCK, name: "bar-chart-race" }]);
expect(loadProjectConfig(dir).registryItems?.map((i) => i.name)).toEqual([
"data-chart",
"bar-chart-race",
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The old guard compared object identity, and `add` always builds fresh
// records, so a redundant re-add always rewrote the file. Pinned through
// the file's own byte-level formatting: a rewrite would re-serialize it.
it("does not rewrite the file when every item is already recorded", () => {
const dir = tmp();
try {
const text = `{\n\t"registry": "https://example.com/r",\n\t"registryItems": [\n\t\t{ "name": "data-chart", "type": "hyperframes:block", "target": "compositions/data-chart.html" }\n\t]\n}\n`;
writeFileSync(projectConfigPath(dir), text, "utf-8");
recordProjectRegistryItems(dir, [{ ...BLOCK }]);
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe(text);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The config is normally committed, so an install must not rewrite keys it
// does not own or reflow the file.
it("preserves unknown keys and the file's own indentation", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r", customKey: 42 }, null, 4),
"utf-8",
);
recordProjectRegistryItems(dir, [BLOCK]);
const text = readFileSync(projectConfigPath(dir), "utf-8");
expect(text).toContain('"customKey": 42');
expect(text).toContain('\n "registry"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not create a config for a project that has none", () => {
const dir = tmp();
try {
recordProjectRegistryItems(dir, [BLOCK]);
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("leaves a corrupt config untouched", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
recordProjectRegistryItems(dir, [BLOCK]);
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// normalizeConfig rebuilds from a whitelist; an omission there would read
// as "this project never installed a catalog item".
it("survives a normalizeConfig round-trip", () => {
expect(normalizeConfig({ registryItems: [BLOCK] }).registryItems).toEqual([BLOCK]);
expect(
normalizeConfig({ registryItems: [{ name: "x" }] as never }).registryItems,
).toBeUndefined();
});
});
});