11import { describe , it , expect , afterEach } from "vitest" ;
2- import { mkdirSync , writeFileSync , rmSync } from "node:fs" ;
2+ import { mkdirSync , mkdtempSync , writeFileSync , rmSync } from "node:fs" ;
33import { join } from "node:path" ;
44import { tmpdir } from "node:os" ;
55import { lintProject , shouldBlockRender } from "./lintProject.js" ;
66import type { ProjectDir } from "./project.js" ;
77
88function tmpProject ( name : string ) : string {
9- const dir = join ( tmpdir ( ) , `hf-test-${ name } -${ Date . now ( ) } ` ) ;
10- mkdirSync ( dir , { recursive : true } ) ;
11- return dir ;
9+ return mkdtempSync ( join ( tmpdir ( ) , `hf-test-${ name } -` ) ) ;
1210}
1311
1412function validHtml ( compId = "main" ) : string {
@@ -121,6 +119,29 @@ describe("lintProject", () => {
121119 expect ( finding ?. selector ) . toBe ( '[data-composition-id="scene"] .title' ) ;
122120 } ) ;
123121
122+ it ( "lints percent-encoded linked CSS filenames that exist decoded on disk" , ( ) => {
123+ const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css" ;
124+ const project = makeProject ( validHtml ( ) , {
125+ "scene.html" : `<html><head><link rel="stylesheet" href="${ encodedFilename } "></head><body>
126+ <div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="2"></div>
127+ <script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
128+ </body></html>` ,
129+ } ) ;
130+ writeFileSync (
131+ join ( project . dir , "compositions" , decodeURIComponent ( encodedFilename ) ) ,
132+ '[data-composition-id="scene"] .title { opacity: 0; }' ,
133+ ) ;
134+
135+ const { results } = lintProject ( project ) ;
136+ const subResult = results . find ( ( result ) => result . file === "compositions/scene.html" ) ;
137+ const finding = subResult ?. result . findings . find (
138+ ( item ) => item . code === "composition_self_attribute_selector" ,
139+ ) ;
140+
141+ expect ( finding ) . toBeDefined ( ) ;
142+ expect ( finding ?. selector ) . toBe ( '[data-composition-id="scene"] .title' ) ;
143+ } ) ;
144+
124145 it ( "aggregates errors across index.html and sub-compositions" , ( ) => {
125146 const project = makeProject ( htmlWithMissingMediaId ( ) , {
126147 "overlay.html" : htmlWithMissingMediaId ( ) ,
@@ -182,6 +203,29 @@ function validHtmlWithAudio(compId = "main"): string {
182203</body></html>` ;
183204}
184205
206+ function validHtmlWithAudioSrc ( src : string ) : string {
207+ return `<html><body>
208+ <div data-composition-id="main" data-width="1920" data-height="1080">
209+ <audio id="music" src="${ src } " data-start="0" data-track-index="0" data-volume="1"></audio>
210+ </div>
211+ <script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
212+ </body></html>` ;
213+ }
214+
215+ function validHtmlWithMaskImageUrl ( url : string ) : string {
216+ return `<html><body>
217+ <div data-composition-id="main" data-width="1920" data-height="1080">
218+ <div class="hf-texture-text hf-texture-lava">TEXT</div>
219+ </div>
220+ <style>
221+ .hf-texture-lava {
222+ mask-image: url("${ url } ");
223+ }
224+ </style>
225+ <script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
226+ </body></html>` ;
227+ }
228+
185229describe ( "audio_file_without_element" , ( ) => {
186230 it ( "warns when audio file exists but no <audio> element" , ( ) => {
187231 const project = makeProject ( validHtml ( ) ) ;
@@ -330,6 +374,46 @@ describe("audio_src_not_found", () => {
330374 expect ( finding ) . toBeUndefined ( ) ;
331375 } ) ;
332376
377+ it ( "does not error for percent-encoded non-Latin filenames that exist on disk" , ( ) => {
378+ const encodedFilename =
379+ "%D9%87%D9%86%D8%A7%20%D9%85%D8%B1%D9%88%D8%A7%20-%20%D9%85%D8%A8%D8%A7%D8%B1%D9%83.mp4" ;
380+ const project = makeProject ( validHtmlWithAudioSrc ( `assets/${ encodedFilename } ` ) ) ;
381+ mkdirSync ( join ( project . dir , "assets" ) , { recursive : true } ) ;
382+ writeFileSync ( join ( project . dir , "assets" , decodeURIComponent ( encodedFilename ) ) , "fake" ) ;
383+
384+ const { results } = lintProject ( project ) ;
385+
386+ const first = results [ 0 ] ;
387+ expect ( first ) . toBeDefined ( ) ;
388+ const finding = first ?. result . findings . find ( ( f ) => f . code === "audio_src_not_found" ) ;
389+ expect ( finding ) . toBeUndefined ( ) ;
390+ } ) ;
391+
392+ it ( "does not error for malformed percent sequences that are literal filenames" , ( ) => {
393+ const filename = "100%-discount.mp4" ;
394+ const project = makeProject ( validHtmlWithAudioSrc ( `assets/${ filename } ` ) ) ;
395+ mkdirSync ( join ( project . dir , "assets" ) , { recursive : true } ) ;
396+ writeFileSync ( join ( project . dir , "assets" , filename ) , "fake" ) ;
397+
398+ const { results } = lintProject ( project ) ;
399+
400+ const first = results [ 0 ] ;
401+ expect ( first ) . toBeDefined ( ) ;
402+ const finding = first ?. result . findings . find ( ( f ) => f . code === "audio_src_not_found" ) ;
403+ expect ( finding ) . toBeUndefined ( ) ;
404+ } ) ;
405+
406+ it ( "does not treat decoded traversal as an existing file outside the project" , ( ) => {
407+ const project = makeProject (
408+ validHtmlWithAudioSrc ( "assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd" ) ,
409+ ) ;
410+
411+ const { results } = lintProject ( project ) ;
412+
413+ const finding = results [ 0 ] ?. result . findings . find ( ( f ) => f . code === "audio_src_not_found" ) ;
414+ expect ( finding ) . toBeDefined ( ) ;
415+ } ) ;
416+
333417 it ( "deduplicates missing files across compositions" , ( ) => {
334418 const project = makeProject ( validHtmlWithAudio ( ) , {
335419 "captions.html" : validHtmlWithAudio ( "captions" ) ,
@@ -467,6 +551,30 @@ describe("texture_mask_asset_not_found", () => {
467551 expect ( finding ) . toBeUndefined ( ) ;
468552 } ) ;
469553
554+ it ( "checks mask-image URLs inside percent-encoded linked CSS filenames" , ( ) => {
555+ const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css" ;
556+ const project = makeProject ( validHtml ( ) , {
557+ "scene.html" : `<html><head><link rel="stylesheet" href="${ encodedFilename } "></head><body>
558+ <div data-composition-id="scene" data-width="1920" data-height="1080">
559+ <div class="hf-texture-text hf-texture-lava">TEXT</div>
560+ </div>
561+ <script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
562+ </body></html>` ,
563+ } ) ;
564+ writeFileSync (
565+ join ( project . dir , "compositions" , decodeURIComponent ( encodedFilename ) ) ,
566+ '.hf-texture-lava { mask-image: url("masks/missing.png"); }' ,
567+ ) ;
568+
569+ const { results } = lintProject ( project ) ;
570+ const finding = results [ 0 ] ?. result . findings . find (
571+ ( item ) => item . code === "texture_mask_asset_not_found" ,
572+ ) ;
573+
574+ expect ( finding ) . toBeDefined ( ) ;
575+ expect ( finding ?. message ) . toContain ( "masks/missing.png" ) ;
576+ } ) ;
577+
470578 it ( "resolves root-absolute mask-image URLs from the project root" , ( ) => {
471579 const html = `<html><body>
472580 <div data-composition-id="main" data-width="1920" data-height="1080">
@@ -493,6 +601,33 @@ describe("texture_mask_asset_not_found", () => {
493601
494602 expect ( finding ) . toBeUndefined ( ) ;
495603 } ) ;
604+
605+ it ( "does not error for percent-encoded non-Latin mask filenames that exist on disk" , ( ) => {
606+ const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.png" ;
607+ const project = makeProject ( validHtmlWithMaskImageUrl ( `assets/${ encodedFilename } ` ) ) ;
608+ mkdirSync ( join ( project . dir , "assets" ) , { recursive : true } ) ;
609+ writeFileSync ( join ( project . dir , "assets" , decodeURIComponent ( encodedFilename ) ) , "fake" ) ;
610+
611+ const { results } = lintProject ( project ) ;
612+ const finding = results [ 0 ] ?. result . findings . find (
613+ ( item ) => item . code === "texture_mask_asset_not_found" ,
614+ ) ;
615+
616+ expect ( finding ) . toBeUndefined ( ) ;
617+ } ) ;
618+
619+ it ( "does not treat decoded mask traversal as an existing file outside the project" , ( ) => {
620+ const project = makeProject (
621+ validHtmlWithMaskImageUrl ( "assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd" ) ,
622+ ) ;
623+
624+ const { results } = lintProject ( project ) ;
625+ const finding = results [ 0 ] ?. result . findings . find (
626+ ( item ) => item . code === "texture_mask_asset_not_found" ,
627+ ) ;
628+
629+ expect ( finding ) . toBeDefined ( ) ;
630+ } ) ;
496631} ) ;
497632
498633describe ( "multiple_root_compositions" , ( ) => {
0 commit comments