mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(lint): flag hidden-style opacity guards (#3694)
This commit is contained in:
@@ -870,6 +870,78 @@ describe("core rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime_hidden_style_opacity", () => {
|
||||
const comp = (css: string, extraMarkup = "") => `
|
||||
<html><head><style>${css}</style></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<video id="footage" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
|
||||
${extraMarkup}
|
||||
</div>
|
||||
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
|
||||
</body></html>`;
|
||||
|
||||
it("errors when a broad hidden-style selector forces replacement-frame opacity to zero", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`[style*="visibility: hidden"] { opacity: 0 !important; }`),
|
||||
);
|
||||
const finding = result.findings.find((item) => item.code === "runtime_hidden_style_opacity");
|
||||
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe(`[style*="visibility: hidden"]`);
|
||||
expect(finding?.message).toContain("replacement frame");
|
||||
expect(finding?.fixHint).toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("errors when composition scoping still leaves the hidden-style selector on video", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`#root > video[style*="visibility: hidden"] { opacity: 0; }`),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.selector,
|
||||
).toBe(`#root > video[style*="visibility: hidden"]`);
|
||||
});
|
||||
|
||||
it("errors when the root stylesheet can affect video mounted from a sub-composition", async () => {
|
||||
const result = await lintHyperframeHtml(`
|
||||
<html><head><style>[style*="visibility: hidden"] { opacity: 0; }</style></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="scene" data-composition-src="scene.html"></div>
|
||||
</div>
|
||||
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
|
||||
</body></html>`);
|
||||
|
||||
expect(
|
||||
result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.severity,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("allows hidden-style opacity guards scoped to sub-composition hosts", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`[data-composition-src][style*="visibility: hidden"],
|
||||
[data-composition-file][style*="visibility: hidden"] { opacity: 0 !important; }`,
|
||||
`<div data-composition-id="scene-a" data-composition-src="scene-a.html"></div>
|
||||
<div data-composition-id="scene-b" data-composition-file="scene-b.html"></div>`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.findings.find((item) => item.code === "runtime_hidden_style_opacity"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows broad hidden-style selectors that do not change opacity", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`[style*="visibility: hidden"] { pointer-events: none; }`),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.findings.find((item) => item.code === "runtime_hidden_style_opacity"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => {
|
||||
it("reports a css_parse_error finding for unparseable CSS", async () => {
|
||||
const html = `<html><body>
|
||||
|
||||
@@ -97,6 +97,53 @@ function resolvedRuleSelectors(rule: postcss.Rule): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function selectorAliasesRuntimeHiddenStyle(selector: string): boolean {
|
||||
let unsafe = false;
|
||||
try {
|
||||
selectorParser((root) => {
|
||||
root.each((selectorNode) => {
|
||||
const subject: selectorParser.Node[] = [];
|
||||
selectorNode.each((node) => {
|
||||
if (node.type === "combinator") subject.length = 0;
|
||||
else subject.push(node);
|
||||
});
|
||||
|
||||
const hostScoped = subject.some(
|
||||
(node) =>
|
||||
node.type === "attribute" &&
|
||||
["data-composition-src", "data-composition-file"].includes(
|
||||
node.attribute.toLowerCase(),
|
||||
),
|
||||
);
|
||||
if (hostScoped) return;
|
||||
|
||||
if (
|
||||
subject.some((node) => {
|
||||
if (node.type !== "attribute" || node.attribute.toLowerCase() !== "style") return false;
|
||||
if (node.operator !== "*=" || !node.value) return false;
|
||||
const needle = node.insensitive ? node.value.toLowerCase() : node.value;
|
||||
if (!needle.includes("visibility") && !needle.includes("hidden")) return false;
|
||||
return "visibility: hidden !important;".includes(needle);
|
||||
})
|
||||
) {
|
||||
unsafe = true;
|
||||
}
|
||||
});
|
||||
}).processSync(selector);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unsafe;
|
||||
}
|
||||
|
||||
function ruleForcesOpacityZero(rule: postcss.Rule): boolean {
|
||||
let forcesOpacityZero = false;
|
||||
rule.walkDecls(/^opacity$/i, (declaration) => {
|
||||
if (Number(declaration.value.trim()) === 0) forcesOpacityZero = true;
|
||||
});
|
||||
return forcesOpacityZero;
|
||||
}
|
||||
|
||||
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
|
||||
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
|
||||
return false;
|
||||
@@ -333,10 +380,11 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// repeated_id_descendant_selector
|
||||
// CSS selector safety
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const reported = new Set<string>();
|
||||
const reportedRepeatedIds = new Set<string>();
|
||||
const reportedHiddenStyleSelectors = new Set<string>();
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
@@ -350,16 +398,36 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
continue;
|
||||
}
|
||||
root.walkRules((rule) => {
|
||||
const forcesOpacityZero = ruleForcesOpacityZero(rule);
|
||||
for (const selector of resolvedRuleSelectors(rule)) {
|
||||
const repeatedId = repeatedDescendantId(selector);
|
||||
if (!repeatedId || reported.has(repeatedId)) continue;
|
||||
reported.add(repeatedId);
|
||||
if (repeatedId && !reportedRepeatedIds.has(repeatedId)) {
|
||||
reportedRepeatedIds.add(repeatedId);
|
||||
findings.push({
|
||||
code: "repeated_id_descendant_selector",
|
||||
severity: "error",
|
||||
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
|
||||
selector,
|
||||
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!forcesOpacityZero ||
|
||||
reportedHiddenStyleSelectors.has(selector) ||
|
||||
!selectorAliasesRuntimeHiddenStyle(selector)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
reportedHiddenStyleSelectors.add(selector);
|
||||
findings.push({
|
||||
code: "repeated_id_descendant_selector",
|
||||
code: "runtime_hidden_style_opacity",
|
||||
severity: "error",
|
||||
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
|
||||
message: `Selector "${selector}" observes HyperFrames' runtime-owned hidden style and forces opacity to zero. The renderer hides each native video before copying its computed opacity to the visible replacement frame, so this rule makes both transparent.`,
|
||||
selector,
|
||||
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
|
||||
fixHint:
|
||||
'Restrict the guard to sub-composition hosts, for example `[data-composition-src][style*="visibility: hidden"]` and `[data-composition-file][style*="visibility: hidden"]`. Do not derive arbitrary element or media opacity from runtime-owned inline visibility.',
|
||||
snippet: truncateSnippet(rule.toString()),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user