mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(parsers): resolve media source writes in their lexical scopes (#5255)
This commit is contained in:
@@ -695,6 +695,31 @@ describe("media rules", () => {
|
||||
expect(findings.every((finding) => finding.fixHint?.includes("data-var-src"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
script: `const target = document.getElementById("clip"); { const target = document.getElementById("poster"); target.src = "b.png"; } target.src = "b.mp4";`,
|
||||
expected: ["clip"],
|
||||
},
|
||||
{
|
||||
script: `const target = document.getElementById("clip"); function update(target) { target.src = "b.png"; }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
script: `function updateVideo() { const target = document.getElementById("clip"); target.src = "b.mp4"; } function updateImage() { const target = document.getElementById("poster"); target.src = "b.png"; }`,
|
||||
expected: ["clip"],
|
||||
},
|
||||
])("checks the media source write in its own scope ($expected)", async ({ script, expected }) => {
|
||||
const html = `<html><body><div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="clip" src="a.mp4"></video><img id="poster" src="a.png">
|
||||
<script>${script}</script></div></body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(
|
||||
result.findings
|
||||
.filter((finding) => finding.code === "media_runtime_src_mutation")
|
||||
.map((finding) => finding.elementId),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it("does not flag img or script src mutations", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractMediaSrcMutations } from "./mediaSrcMutation.js";
|
||||
|
||||
describe("extractMediaSrcMutations", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "block",
|
||||
script: `const media = document.getElementById("video"); { const media = document.getElementById("image"); media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "sibling functions",
|
||||
script: `function updateImage() { const media = document.getElementById("image"); media.src = "image.png"; } function updateVideo() { const media = document.getElementById("video"); media.src = "new.mp4"; }`,
|
||||
},
|
||||
{
|
||||
label: "arrow function",
|
||||
script: `const media = document.getElementById("video"); const updateImage = () => { const media = document.getElementById("image"); media.src = "image.png"; }; media.src = "new.mp4";`,
|
||||
},
|
||||
])("keeps $label bindings separate", ({ script }) => {
|
||||
expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
|
||||
"#image",
|
||||
"#video",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "parameter",
|
||||
script: `const media = document.getElementById("video"); function update(media) { media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "destructured parameter",
|
||||
script: `const media = document.getElementById("video"); function update({ media }) { media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "unknown local",
|
||||
script: `const media = document.getElementById("video"); { const media = findImage(); media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "destructured local",
|
||||
script: `const media = document.getElementById("video"); { const { media } = assets; media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "catch parameter",
|
||||
script: `const media = document.getElementById("video"); try { update(); } catch(media) { media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
{
|
||||
label: "loop variable",
|
||||
script: `const media = document.getElementById("video"); for (const media of images) { media.src = "image.png"; } media.src = "new.mp4";`,
|
||||
},
|
||||
])("does not inherit an outer target through a $label", ({ script }) => {
|
||||
expect(extractMediaSrcMutations(script)).toEqual([
|
||||
{ selector: "#video", operation: "src_assignment", raw: `media.src = "new.mp4"` },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a closure and its scoped aliases", () => {
|
||||
const script = `const media = document.getElementById("video"); { const alias = media; function update() { alias.setAttribute("src", "new.mp4"); } }`;
|
||||
expect(extractMediaSrcMutations(script)).toEqual([
|
||||
{
|
||||
selector: "#video",
|
||||
operation: "set_attribute",
|
||||
raw: `alias.setAttribute("src", "new.mp4")`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps var declarations in their enclosing function", () => {
|
||||
const script = `function update() { { var media = document.getElementById("video"); } media.src = "new.mp4"; }`;
|
||||
expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
|
||||
"#video",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains a parameter that is redeclared and initialized with var", () => {
|
||||
const script = `function update(media) { var media = document.getElementById("video"); media.src = "new.mp4"; }`;
|
||||
expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
|
||||
"#video",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inherit an outer target through a named class expression", () => {
|
||||
const script = `const media = document.getElementById("video"); const View = class media { update() { media.src = "not-a-video"; } }; media.src = "new.mp4";`;
|
||||
expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
|
||||
"#video",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not leak block declarations outside their scope", () => {
|
||||
const script = `{ const media = document.getElementById("video"); } media.src = "new.mp4";`;
|
||||
expect(extractMediaSrcMutations(script)).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains direct and computed DOM lookups", () => {
|
||||
const script = `document["getElementById"]("video")["src"] = "new.mp4"; document.querySelector("#audio").setAttribute("SRC", "new.wav");`;
|
||||
expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
|
||||
"#video",
|
||||
"#audio",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps module parsing and rejects malformed scripts", () => {
|
||||
expect(
|
||||
extractMediaSrcMutations(
|
||||
`export const media = document.getElementById("video"); media.src = "new.mp4";`,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(extractMediaSrcMutations("const media = ;")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not recurse through circular aliases", () => {
|
||||
expect(extractMediaSrcMutations(`let a = b; let b = a; a.src = "new.mp4";`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,17 @@ export interface MediaSrcMutation {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function parseProgram(script: string): any {
|
||||
interface Binding {
|
||||
initializer: acorn.Expression | null;
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
parent?: Scope;
|
||||
bindings: Map<string, Binding>;
|
||||
}
|
||||
|
||||
function parseProgram(script: string): acorn.Program {
|
||||
try {
|
||||
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" });
|
||||
} catch {
|
||||
@@ -15,28 +25,183 @@ function parseProgram(script: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
function literalString(node: any): string | undefined {
|
||||
function literalString(node: acorn.AnyNode | null | undefined): string | undefined {
|
||||
if (node?.type === "Literal" && typeof node.value === "string") return node.value;
|
||||
if (node?.type === "TemplateLiteral" && node.expressions?.length === 0) {
|
||||
return node.quasis?.[0]?.value?.cooked;
|
||||
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
|
||||
return node.quasis[0]?.value.cooked ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function memberName(node: any): string | undefined {
|
||||
function memberName(node: acorn.AnyNode | null | undefined): string | undefined {
|
||||
if (node?.type !== "MemberExpression") return undefined;
|
||||
if (!node.computed && node.property?.type === "Identifier") return node.property.name;
|
||||
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
||||
return literalString(node.property);
|
||||
}
|
||||
|
||||
function lookupSelector(node: any, bindings: ReadonlyMap<string, string>): string | undefined {
|
||||
if (node?.type === "Identifier") return bindings.get(node.name);
|
||||
if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") {
|
||||
function isFunctionScope(node: acorn.AnyNode): boolean {
|
||||
return (
|
||||
node.type === "Program" ||
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression" ||
|
||||
node.type === "ArrowFunctionExpression" ||
|
||||
node.type === "StaticBlock"
|
||||
);
|
||||
}
|
||||
|
||||
function isScope(node: acorn.AnyNode): boolean {
|
||||
return (
|
||||
isFunctionScope(node) ||
|
||||
node.type === "BlockStatement" ||
|
||||
node.type === "CatchClause" ||
|
||||
node.type === "ForStatement" ||
|
||||
node.type === "ForInStatement" ||
|
||||
node.type === "ForOfStatement" ||
|
||||
node.type === "SwitchStatement" ||
|
||||
node.type === "ClassDeclaration" ||
|
||||
node.type === "ClassExpression"
|
||||
);
|
||||
}
|
||||
|
||||
function declarePattern(
|
||||
pattern: acorn.Pattern,
|
||||
initializer: acorn.Expression | null,
|
||||
scope: Scope,
|
||||
initializerScope = scope,
|
||||
): void {
|
||||
switch (pattern.type) {
|
||||
case "Identifier":
|
||||
if (initializer || !scope.bindings.has(pattern.name)) {
|
||||
scope.bindings.set(pattern.name, { initializer, scope: initializerScope });
|
||||
}
|
||||
break;
|
||||
case "RestElement":
|
||||
declarePattern(pattern.argument, null, scope);
|
||||
break;
|
||||
case "AssignmentPattern":
|
||||
declarePattern(pattern.left, null, scope);
|
||||
break;
|
||||
case "ArrayPattern":
|
||||
case "ObjectPattern":
|
||||
declarePatterns(destructuredPatterns(pattern), scope);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function declarePatterns(patterns: readonly acorn.Pattern[], scope: Scope): void {
|
||||
for (const pattern of patterns) declarePattern(pattern, null, scope);
|
||||
}
|
||||
|
||||
function destructuredPatterns(pattern: acorn.ObjectPattern | acorn.ArrayPattern): acorn.Pattern[] {
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
return pattern.properties.map((property) =>
|
||||
property.type === "RestElement" ? property.argument : property.value,
|
||||
);
|
||||
}
|
||||
return pattern.elements.filter((element): element is acorn.Pattern => element !== null);
|
||||
}
|
||||
|
||||
function declareOptionalPattern(pattern: acorn.Pattern | null | undefined, scope: Scope): void {
|
||||
if (pattern) declarePattern(pattern, null, scope);
|
||||
}
|
||||
|
||||
function declareScopedName(node: acorn.AnyNode, scope: Scope): void {
|
||||
switch (node.type) {
|
||||
case "FunctionExpression":
|
||||
case "FunctionDeclaration":
|
||||
declareOptionalPattern(node.id, scope);
|
||||
declarePatterns(node.params, scope);
|
||||
break;
|
||||
case "ArrowFunctionExpression":
|
||||
declarePatterns(node.params, scope);
|
||||
break;
|
||||
case "ClassDeclaration":
|
||||
case "ClassExpression":
|
||||
declareOptionalPattern(node.id, scope);
|
||||
break;
|
||||
case "CatchClause":
|
||||
declareOptionalPattern(node.param, scope);
|
||||
break;
|
||||
case "ImportDeclaration":
|
||||
declarePatterns(
|
||||
node.specifiers.map((specifier) => specifier.local),
|
||||
scope,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function lookupBinding(name: string, scope: Scope): Binding | undefined {
|
||||
for (let current: Scope | undefined = scope; current; current = current.parent) {
|
||||
const binding = current.bindings.get(name);
|
||||
if (binding) return binding;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectBindings(
|
||||
ast: acorn.Program,
|
||||
): (ancestors: readonly acorn.AnyNode[], functionOnly?: boolean) => Scope {
|
||||
const root: Scope = { bindings: new Map() };
|
||||
const scopes = new WeakMap<acorn.AnyNode, Scope>([[ast, root]]);
|
||||
const scopeAt = (ancestors: readonly acorn.AnyNode[], functionOnly = false): Scope => {
|
||||
let parent = root;
|
||||
let selected = root;
|
||||
for (const node of ancestors) {
|
||||
if (!isScope(node)) continue;
|
||||
let scope = scopes.get(node);
|
||||
if (!scope) {
|
||||
scope = { parent, bindings: new Map() };
|
||||
scopes.set(node, scope);
|
||||
}
|
||||
parent = scope;
|
||||
if (!functionOnly || isFunctionScope(node)) selected = scope;
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
acornWalk.fullAncestor(ast, (node, _state, ancestors) => {
|
||||
const scope = scopeAt(ancestors);
|
||||
if (node.type === "VariableDeclarator") {
|
||||
const declaration = ancestors.at(-2);
|
||||
const bindingScope = scopeAt(
|
||||
ancestors,
|
||||
declaration?.type === "VariableDeclaration" && declaration.kind === "var",
|
||||
);
|
||||
declarePattern(node.id, node.init ?? null, bindingScope, scope);
|
||||
return;
|
||||
}
|
||||
if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id) {
|
||||
declarePattern(node.id, null, scopeAt(ancestors.slice(0, -1)));
|
||||
}
|
||||
declareScopedName(node, scope);
|
||||
});
|
||||
return scopeAt;
|
||||
}
|
||||
|
||||
function lookupSelector(
|
||||
node: acorn.Expression | acorn.Super | null | undefined,
|
||||
scope: Scope,
|
||||
seen = new Set<Binding>(),
|
||||
): string | undefined {
|
||||
if (node?.type === "Identifier") {
|
||||
const binding = lookupBinding(node.name, scope);
|
||||
if (!binding || seen.has(binding)) return undefined;
|
||||
seen.add(binding);
|
||||
return lookupSelector(binding.initializer, binding.scope, seen);
|
||||
}
|
||||
return selectorFromDomLookup(node);
|
||||
}
|
||||
|
||||
function selectorFromDomLookup(
|
||||
node: acorn.Expression | acorn.Super | null | undefined,
|
||||
): string | undefined {
|
||||
if (node?.type !== "CallExpression" || node.callee.type !== "MemberExpression") {
|
||||
return undefined;
|
||||
}
|
||||
const method = memberName(node.callee);
|
||||
if (method !== "getElementById" && method !== "querySelector") return undefined;
|
||||
const value = literalString(node.arguments?.[0]);
|
||||
const value = literalString(node.arguments[0]);
|
||||
if (!value) return undefined;
|
||||
return method === "getElementById" ? `#${value}` : value;
|
||||
}
|
||||
@@ -45,20 +210,17 @@ function lookupSelector(node: any, bindings: ReadonlyMap<string, string>): strin
|
||||
export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
|
||||
try {
|
||||
const ast = parseProgram(script);
|
||||
const bindings = new Map<string, string>();
|
||||
acornWalk.simple(ast, {
|
||||
VariableDeclarator(node: any) {
|
||||
if (node.id?.type !== "Identifier") return;
|
||||
const selector = lookupSelector(node.init, bindings);
|
||||
if (selector) bindings.set(node.id.name, selector);
|
||||
},
|
||||
});
|
||||
|
||||
const scopeAt = collectBindings(ast);
|
||||
const mutations: MediaSrcMutation[] = [];
|
||||
acornWalk.simple(ast, {
|
||||
AssignmentExpression(node: any) {
|
||||
if (node.operator !== "=" || memberName(node.left) !== "src") return;
|
||||
const selector = lookupSelector(node.left.object, bindings);
|
||||
acornWalk.ancestor(ast, {
|
||||
AssignmentExpression(node, _state, ancestors) {
|
||||
if (
|
||||
node.operator !== "=" ||
|
||||
node.left.type !== "MemberExpression" ||
|
||||
memberName(node.left) !== "src"
|
||||
)
|
||||
return;
|
||||
const selector = lookupSelector(node.left.object, scopeAt(ancestors));
|
||||
if (!selector) return;
|
||||
mutations.push({
|
||||
selector,
|
||||
@@ -66,10 +228,11 @@ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
|
||||
raw: script.slice(node.start, node.end),
|
||||
});
|
||||
},
|
||||
CallExpression(node: any) {
|
||||
if (memberName(node.callee) !== "setAttribute") return;
|
||||
if (literalString(node.arguments?.[0])?.toLowerCase() !== "src") return;
|
||||
const selector = lookupSelector(node.callee.object, bindings);
|
||||
CallExpression(node, _state, ancestors) {
|
||||
if (node.callee.type !== "MemberExpression" || memberName(node.callee) !== "setAttribute")
|
||||
return;
|
||||
if (literalString(node.arguments[0])?.toLowerCase() !== "src") return;
|
||||
const selector = lookupSelector(node.callee.object, scopeAt(ancestors));
|
||||
if (!selector) return;
|
||||
mutations.push({
|
||||
selector,
|
||||
|
||||
@@ -1034,6 +1034,45 @@ describe("runProbeStage — forceScreenshot threading", () => {
|
||||
expect(mediaPreflightComposition).toBe(input.composition);
|
||||
});
|
||||
|
||||
it("probes a video source write after an inner binding shadows its variable", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 5;
|
||||
input.composition.videos.push({
|
||||
id: "clip",
|
||||
src: "a.mp4",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
});
|
||||
input.compiled.html = `<video id="clip" src="a.mp4"></video><img id="poster" src="a.png">
|
||||
<script>const target = document.getElementById("clip");
|
||||
{ const target = document.getElementById("poster"); target.src = "b.png"; }
|
||||
target.src = "b.mp4";</script>`;
|
||||
browserMediaResults = [
|
||||
{
|
||||
id: "clip",
|
||||
tagName: "video",
|
||||
src: "b.mp4",
|
||||
start: 0,
|
||||
end: 5,
|
||||
duration: 5,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
volume: 1,
|
||||
muted: true,
|
||||
},
|
||||
];
|
||||
await runProbeStage(input);
|
||||
expect(capturedCfgs.length).toBeGreaterThan(0);
|
||||
expect(input.composition.videos[0]?.src).toBe("b.mp4");
|
||||
});
|
||||
|
||||
it("does not probe for img or script src mutations", async () => {
|
||||
resetRetryMocks();
|
||||
capturedCfgs.length = 0;
|
||||
|
||||
Reference in New Issue
Block a user