Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions packages/cli/src/commands/coreSkillContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ describe("hyperframes-core contract docs", () => {
expect(renderReference).toContain("OUTCOME:");
expect(renderReference).toContain("WORKAROUND:");
});

it("mandates a composition-structure block for visual-defect feedback", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const renderReference = read("skills", "hyperframes-cli", "references", "preview-render.md");

// Skill teaches the mandate at a high level.
expect(skill).toContain("COMPOSITION_STRUCTURE:");
// Reference carries the fillable block + agent-helper pointer.
expect(renderReference).toContain("COMPOSITION_STRUCTURE:");
expect(renderReference).toContain("elements: video=");
expect(renderReference).toContain("attributes:");
expect(renderReference).toContain("timeline:");
expect(renderReference).toContain("buildCompositionCensus");
});
});

describe("media-use TTS documentation", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
import { VERSION } from "../version.js";
import { c } from "../ui/colors.js";
import { parseFeedbackRating } from "../utils/feedbackRating.js";
import { lintFeedbackComment, type FeedbackLintInput } from "../utils/feedbackLint.js";

export const examples: Example[] = [
["Submit render feedback", 'hyperframes feedback --rating 8 --comment "fast but font missing"'],
Expand Down Expand Up @@ -78,6 +79,18 @@ async function publishRepro(dir: string): Promise<string | undefined> {
}
}

/**
* Print soft-warn feedback-lint messages to stdout. Extracted so the
* command's `run` stays a flat control-flow driver — the warning loop is
* incidental to the command logic and its complexity would otherwise push
* `run` over the Fallow CRAP threshold.
*/
function printFeedbackLintWarnings(input: FeedbackLintInput): void {
for (const warning of lintFeedbackComment(input)) {
console.log(c.warn(`⚠ ${warning.message}`));
}
}

async function openAndPrintIssue(url: string): Promise<void> {
if (process.stdout.isTTY) {
try {
Expand Down Expand Up @@ -156,6 +169,11 @@ export default defineCommand({
const comment = normalizeComment(args.comment);
const doctorSummary = await getDoctorSummary();

// Soft-warn (never blocks) when the comment for a non-clean report is
// missing the mandated reproduction-packet markers. Prints before the
// submission ack so the reporter sees the nudge while their run is fresh.
printFeedbackLintWarnings({ rating, comment });

// The standalone command runs separately from `render`, so it has no real
// elapsed time to report. Omit it rather than recording a fake duration.
trackRenderFeedback({ rating, comment, doctorSummary });
Expand Down
135 changes: 135 additions & 0 deletions packages/cli/src/utils/compositionCensus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";

import { buildCompositionCensus, renderCompositionCensusBlock } from "./compositionCensus.js";

const MINIMAL_HTML = `<!doctype html>
<html>
<body>
<div data-composition-id="main" data-start="0" data-duration="5"></div>
</body>
</html>`;

const RICH_HTML = `<!doctype html>
<html>
<head>
<style>
.card { filter: blur(4px); mix-blend-mode: multiply; }
.fixed-bar { position: fixed; overflow: hidden; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
</head>
<body>
<div data-composition-id="main" data-start="0" data-duration="10">
<video data-has-audio="true"></video>
<video></video>
<audio></audio>
<img />
<img />
<img />
<svg></svg>
<canvas></canvas>
<div data-composition-src="scenes/intro.html" data-start="0"></div>
<div data-composition-src="scenes/outro.html" data-start="5"></div>
<div style="clip-path: circle(50%); transform: translateX(10px); z-index: 3"></div>
<div style="background-image: url('bg.png')"></div>
<div style="mask-image: url('mask.svg')"></div>
</div>
<script>
gsap.timeline().to(".card", { x: 100 });
</script>
</body>
</html>`;

describe("buildCompositionCensus", () => {
it("counts zero media on a minimal composition", () => {
const c = buildCompositionCensus(MINIMAL_HTML);
expect(c.elementCensus).toEqual({
video: 0,
audio: 0,
img: 0,
svg: 0,
canvas: 0,
subCompositionMounts: 0,
});
expect(c.timelineShape.nested).toBe(false);
expect(c.timelineShape.subCompositionCount).toBe(0);
expect(c.timelineShape.usesGsap).toBe(false);
expect(c.timelineShape.usesDataTimeline).toBe(true);
});

it("counts each element category on a rich composition", () => {
const c = buildCompositionCensus(RICH_HTML);
expect(c.elementCensus).toEqual({
video: 2,
audio: 1,
img: 3,
svg: 1,
canvas: 1,
subCompositionMounts: 2,
});
});

it("detects structural attributes from both inline style and <style> rules", () => {
const c = buildCompositionCensus(RICH_HTML);
// Inline-style probes
expect(c.structuralAttributes.clipPath).toBe(true);
expect(c.structuralAttributes.transform).toBe(true);
expect(c.structuralAttributes.zIndex).toBe(true);
expect(c.structuralAttributes.backgroundImage).toBe(true);
expect(c.structuralAttributes.maskImage).toBe(true);
// <style> tag probes
expect(c.structuralAttributes.filter).toBe(true);
expect(c.structuralAttributes.mixBlendMode).toBe(true);
expect(c.structuralAttributes.positionFixed).toBe(true);
expect(c.structuralAttributes.overflowHidden).toBe(true);
// data-* attribute probes
expect(c.structuralAttributes.dataHasAudio).toBe(true);
expect(c.structuralAttributes.dataDuration).toBe(true);
expect(c.structuralAttributes.dataStart).toBe(true);
expect(c.structuralAttributes.dataCompositionSrc).toBe(true);
});

it("reports absent attributes as false on minimal HTML", () => {
const c = buildCompositionCensus(MINIMAL_HTML);
expect(c.structuralAttributes.clipPath).toBe(false);
expect(c.structuralAttributes.filter).toBe(false);
expect(c.structuralAttributes.mixBlendMode).toBe(false);
expect(c.structuralAttributes.dataHasAudio).toBe(false);
expect(c.structuralAttributes.backgroundImage).toBe(false);
expect(c.structuralAttributes.maskImage).toBe(false);
});

it("detects gsap from both script src and inline gsap.* calls", () => {
expect(buildCompositionCensus(RICH_HTML).timelineShape.usesGsap).toBe(true);
const inlineOnly = `<html><body><div data-composition-id="m"></div><script>gsap.to('.x', {})</script></body></html>`;
expect(buildCompositionCensus(inlineOnly).timelineShape.usesGsap).toBe(true);
const noGsap = `<html><body><div data-composition-id="m"></div><script>console.log('hi')</script></body></html>`;
expect(buildCompositionCensus(noGsap).timelineShape.usesGsap).toBe(false);
});

it("marks timelines as nested when sub-comp mounts exist", () => {
const c = buildCompositionCensus(RICH_HTML);
expect(c.timelineShape.nested).toBe(true);
expect(c.timelineShape.subCompositionCount).toBe(2);
});
});

describe("renderCompositionCensusBlock", () => {
it("emits a REPRO-packet-compatible block starting with the mandated header", () => {
const block = renderCompositionCensusBlock(buildCompositionCensus(RICH_HTML));
expect(block.startsWith("COMPOSITION_STRUCTURE:")).toBe(true);
expect(block).toContain("elements: video=2 audio=1 img=3 svg=1 canvas=1 subComps=2");
expect(block).toContain("attributes:");
expect(block).toContain("timeline: nested (2 sub-comps); driver=gsap+data-timeline");
// Placeholder slots the parser can't infer.
expect(block).toContain("delta:");
expect(block).toContain("defect:");
});

it("emits '(none present)' on the attributes line when no structural attrs are found", () => {
const empty = `<html><body><div data-composition-id="m"></div></body></html>`;
const block = renderCompositionCensusBlock(buildCompositionCensus(empty));
expect(block).toContain("attributes: (none present)");
expect(block).toContain("timeline: flat; driver=none");
});
});
Loading
Loading