mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 12:17:03 +00:00
* 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
466 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|