11#!/usr/bin/env node
2-
32const fs = require ( "fs" ) ;
43const http = require ( "http" ) ;
54const path = require ( "path" ) ;
65const { chromium } = require ( "playwright" ) ;
7-
86const ROOT = path . resolve ( __dirname , ".." ) ;
97const ARTIFACT_DIR = path . join ( ROOT , "data" , "test_reports" , "artifacts" ) ;
10-
118const MIME = {
129 ".html" : "text/html; charset=utf-8" ,
1310 ".css" : "text/css; charset=utf-8" ,
@@ -20,14 +17,12 @@ const MIME = {
2017 ".pdf" : "application/pdf" ,
2118 ".txt" : "text/plain; charset=utf-8" ,
2219} ;
23-
2420function startStaticServer ( rootDir ) {
2521 const server = http . createServer ( ( req , res ) => {
2622 try {
2723 const reqPath = decodeURIComponent ( ( req . url || "/" ) . split ( "?" ) [ 0 ] ) ;
2824 const normalized = path . normalize ( reqPath ) . replace ( / ^ \/ + / , "" ) ;
2925 let filePath = path . join ( rootDir , normalized || "index.html" ) ;
30-
3126 if ( ! filePath . startsWith ( rootDir ) ) {
3227 res . writeHead ( 403 , { "Content-Type" : "text/plain; charset=utf-8" } ) ;
3328 res . end ( "Forbidden" ) ;
@@ -41,7 +36,6 @@ function startStaticServer(rootDir) {
4136 res . end ( "Not found" ) ;
4237 return ;
4338 }
44-
4539 const ext = path . extname ( filePath ) . toLowerCase ( ) ;
4640 res . writeHead ( 200 , { "Content-Type" : MIME [ ext ] || "application/octet-stream" } ) ;
4741 fs . createReadStream ( filePath ) . pipe ( res ) ;
@@ -50,15 +44,13 @@ function startStaticServer(rootDir) {
5044 res . end ( `Server error: ${ error . message } ` ) ;
5145 }
5246 } ) ;
53-
5447 return new Promise ( ( resolve , reject ) => {
5548 server . on ( "error" , reject ) ;
5649 server . listen ( 0 , "127.0.0.1" , ( ) => {
5750 resolve ( { server, port : server . address ( ) . port } ) ;
5851 } ) ;
5952 } ) ;
6053}
61-
6254function toFilePathFromDataUrl ( dataUrl , targetPath ) {
6355 const marker = "base64," ;
6456 const index = String ( dataUrl || "" ) . indexOf ( marker ) ;
@@ -67,19 +59,16 @@ function toFilePathFromDataUrl(dataUrl, targetPath) {
6759 }
6860 fs . writeFileSync ( targetPath , Buffer . from ( dataUrl . slice ( index + marker . length ) , "base64" ) ) ;
6961}
70-
7162async function collectCanvasProbe ( page ) {
7263 return page . evaluate ( async ( ) => {
7364 const pages = getNonEmptyPageElements ( ) ;
7465 const results = [ ] ;
75-
7666 for ( let idx = 0 ; idx < pages . length ; idx += 1 ) {
7767 const canvas = await renderExportPageToCanvas ( pages [ idx ] ) ;
7868 const ctx = canvas . getContext ( "2d" , { willReadFrequently : true } ) ;
7969 if ( ! ctx ) {
8070 throw new Error ( "Canvas 2D context unavailable for export probe." ) ;
8171 }
82-
8372 const w = canvas . width ;
8473 const h = canvas . height ;
8574 const step = Math . max ( 1 , Math . floor ( Math . min ( w , h ) / 700 ) ) ;
@@ -90,7 +79,6 @@ async function collectCanvasProbe(page) {
9079 let maxX = - 1 ;
9180 let maxY = - 1 ;
9281 const bottomStart = Math . floor ( h * 0.8 ) ;
93-
9482 for ( let y = 0 ; y < h ; y += step ) {
9583 for ( let x = 0 ; x < w ; x += step ) {
9684 const [ r , g , b , a ] = ctx . getImageData ( x , y , 1 , 1 ) . data ;
@@ -104,7 +92,6 @@ async function collectCanvasProbe(page) {
10492 if ( y > maxY ) maxY = y ;
10593 }
10694 }
107-
10895 const sampled = Math . max ( 1 , Math . ceil ( w / step ) * Math . ceil ( h / step ) ) ;
10996 const bboxHeightRatio = maxY >= 0 ? ( maxY - minY + 1 ) / Math . max ( 1 , h ) : 0 ;
11097 const bottomInkRatio = ink > 0 ? bottomInk / ink : 0 ;
@@ -121,7 +108,6 @@ async function collectCanvasProbe(page) {
121108 dataUrl : canvas . toDataURL ( "image/png" ) ,
122109 } ) ;
123110 }
124-
125111 const minBBoxHeightRatio = results . length ? Math . min ( ...results . map ( ( item ) => item . bboxHeightRatio ) ) : 0 ;
126112 const minBottomInkRatio = results . length ? Math . min ( ...results . map ( ( item ) => item . bottomInkRatio ) ) : 0 ;
127113 return {
@@ -132,15 +118,13 @@ async function collectCanvasProbe(page) {
132118 } ;
133119 } ) ;
134120}
135-
136121async function collectInlineWrapProbe ( page ) {
137122 return page . evaluate ( async ( ) => {
138123 const pageEl = refs . page1Content ?. parentElement ;
139124 const pageContent = refs . page1Content ;
140125 if ( ! pageEl || ! pageContent ) {
141126 throw new Error ( "Page 1 element unavailable for inline-wrap probe." ) ;
142127 }
143-
144128 const wrapCases = [
145129 {
146130 id : "short_dictionary" ,
@@ -167,7 +151,6 @@ async function collectInlineWrapProbe(page) {
167151 minCodeLines : 2 ,
168152 } ,
169153 ] ;
170-
171154 const host = document . createElement ( "article" ) ;
172155 host . id = "exportInlineWrapProbeHost" ;
173156 host . className = "preview-card" ;
@@ -182,7 +165,6 @@ async function collectInlineWrapProbe(page) {
182165 pointerEvents : "none" ,
183166 borderRadius : "8px" ,
184167 } ) ;
185-
186168 const body = document . createElement ( "div" ) ;
187169 body . className = "preview-body" ;
188170 body . style . paddingTop = "4px" ;
@@ -201,14 +183,12 @@ async function collectInlineWrapProbe(page) {
201183 . join ( "" ) ;
202184 host . appendChild ( body ) ;
203185 pageContent . appendChild ( host ) ;
204-
205186 try {
206187 const caseEls = Array . from ( host . querySelectorAll ( ".export-inline-wrap-case" ) ) ;
207188 const codeEl = host . querySelector ( "code.inline-code" ) ;
208189 if ( ! caseEls . length || ! codeEl ) {
209190 throw new Error ( "Inline-wrap probe host did not render expected elements." ) ;
210191 }
211-
212192 const readInlineStyle = ( ) => {
213193 const style = window . getComputedStyle ( codeEl ) ;
214194 return {
@@ -255,14 +235,12 @@ async function collectInlineWrapProbe(page) {
255235 . filter ( ( key ) => norm ( normalStyle [ key ] ) !== norm ( exportStyle [ key ] ) )
256236 . map ( ( key ) => ( { key, normal : normalStyle [ key ] , export : exportStyle [ key ] } ) ) ;
257237 const styleParityOk = styleMismatches . length === 0 ;
258-
259238 const pageRect = pageEl . getBoundingClientRect ( ) ;
260239 const canvas = await renderExportPageToCanvas ( pageEl , { scale : 2 } ) ;
261240 const ctx = canvas . getContext ( "2d" , { willReadFrequently : true } ) ;
262241 if ( ! ctx ) {
263242 throw new Error ( "Canvas 2D context unavailable for inline-wrap probe." ) ;
264243 }
265-
266244 const scaleX = canvas . width / Math . max ( 1 , pageRect . width ) ;
267245 const scaleY = canvas . height / Math . max ( 1 , pageRect . height ) ;
268246 const toRelative = ( rect ) => ( {
@@ -271,7 +249,6 @@ async function collectInlineWrapProbe(page) {
271249 w : rect . width ,
272250 h : rect . height ,
273251 } ) ;
274-
275252 const sampleRect = ( rect , padX = 0 , padY = 0 ) => {
276253 const relX = Math . max ( 0 , rect . x - padX ) ;
277254 const relY = Math . max ( 0 , rect . y - padY ) ;
@@ -282,42 +259,73 @@ async function collectInlineWrapProbe(page) {
282259 const rw = Math . max ( 6 , Math . floor ( relW * scaleX ) ) ;
283260 const rh = Math . max ( 6 , Math . floor ( relH * scaleY ) ) ;
284261 const step = Math . max ( 1 , Math . floor ( Math . min ( rw , rh ) / 80 ) ) ;
285-
262+ const image = ctx . getImageData ( rx , ry , rw , rh ) . data ;
286263 let sampled = 0 ;
287264 let ink = 0 ;
288265 let tint = 0 ;
289266 let minInkX = rw ;
290- let maxInkX = - 1 ;
291-
292- const maxX = Math . min ( canvas . width , rx + rw ) ;
293- const maxY = Math . min ( canvas . height , ry + rh ) ;
294- for ( let py = ry ; py < maxY ; py += step ) {
295- for ( let px = rx ; px < maxX ; px += step ) {
267+ let sawInk = false ;
268+ for ( let localY = 0 ; localY < rh ; localY += step ) {
269+ for ( let localX = 0 ; localX < rw ; localX += step ) {
270+ const idx = ( localY * rw + localX ) * 4 ;
296271 sampled += 1 ;
297- const [ r , g , b , a ] = ctx . getImageData ( px , py , 1 , 1 ) . data ;
272+ const r = image [ idx ] ;
273+ const g = image [ idx + 1 ] ;
274+ const b = image [ idx + 2 ] ;
275+ const a = image [ idx + 3 ] ;
298276 const isInk = a > 8 && ( r < 246 || g < 246 || b < 246 ) ;
299277 if ( isInk ) {
300278 ink += 1 ;
301- const localX = px - rx ;
279+ sawInk = true ;
302280 if ( localX < minInkX ) minInkX = localX ;
303- if ( localX > maxInkX ) maxInkX = localX ;
304281 }
305282 const spread = Math . max ( r , g , b ) - Math . min ( r , g , b ) ;
306283 const isCodeTint = a > 8 && r >= 220 && g >= 220 && b >= 220 && r <= 252 && g <= 252 && b <= 252 && spread <= 18 ;
307284 if ( isCodeTint ) tint += 1 ;
308285 }
309286 }
310-
311287 const denom = Math . max ( 1 , sampled ) ;
312288 return {
313289 sampled : denom ,
314290 inkRatio : ink / denom ,
315291 tintRatio : tint / denom ,
316- minInkXRatio : maxInkX >= 0 ? minInkX / Math . max ( 1 , rw ) : 1 ,
292+ minInkXRatio : sawInk ? minInkX / Math . max ( 1 , rw ) : 1 ,
317293 canvasBox : { x : rx , y : ry , w : rw , h : rh } ,
318294 } ;
319295 } ;
320-
296+ const sampleOutsideTintRatio = ( paragraphBox , codeBox ) => {
297+ const px = Math . max ( 0 , Math . floor ( paragraphBox . x ) ) ;
298+ const py = Math . max ( 0 , Math . floor ( paragraphBox . y ) ) ;
299+ const pw = Math . max ( 6 , Math . floor ( paragraphBox . w ) ) ;
300+ const ph = Math . max ( 6 , Math . floor ( paragraphBox . h ) ) ;
301+ const step = Math . max ( 1 , Math . floor ( Math . min ( pw , ph ) / 80 ) ) ;
302+ const image = ctx . getImageData ( px , py , pw , ph ) . data ;
303+ const codeLeft = Math . floor ( codeBox . x ) ;
304+ const codeTop = Math . floor ( codeBox . y ) ;
305+ const codeRight = codeLeft + Math . floor ( codeBox . w ) ;
306+ const codeBottom = codeTop + Math . floor ( codeBox . h ) ;
307+ let sampledOutside = 0 ;
308+ let tintedOutside = 0 ;
309+ for ( let localY = 0 ; localY < ph ; localY += step ) {
310+ const globalY = py + localY ;
311+ for ( let localX = 0 ; localX < pw ; localX += step ) {
312+ const globalX = px + localX ;
313+ const inCode = globalX >= codeLeft && globalX < codeRight && globalY >= codeTop && globalY < codeBottom ;
314+ if ( inCode ) continue ;
315+ sampledOutside += 1 ;
316+ const idx = ( localY * pw + localX ) * 4 ;
317+ const r = image [ idx ] ;
318+ const g = image [ idx + 1 ] ;
319+ const b = image [ idx + 2 ] ;
320+ const a = image [ idx + 3 ] ;
321+ const spread = Math . max ( r , g , b ) - Math . min ( r , g , b ) ;
322+ const isCodeTint = a > 8 && r >= 220 && g >= 220 && b >= 220 && r <= 252 && g <= 252 && b <= 252 && spread <= 18 ;
323+ if ( isCodeTint ) tintedOutside += 1 ;
324+ }
325+ }
326+ if ( sampledOutside < 1 ) return 0 ;
327+ return tintedOutside / sampledOutside ;
328+ } ;
321329 const caseResults = caseEls . map ( ( element ) => {
322330 const caseId = element . getAttribute ( "data-case-id" ) || "" ;
323331 const config = wrapCases . find ( ( item ) => item . id === caseId ) || wrapCases [ 0 ] ;
@@ -327,24 +335,22 @@ async function collectInlineWrapProbe(page) {
327335 if ( ! code || ! prefix || ! suffix ) {
328336 return { id : caseId , ok : false , error : "Expected probe spans are missing." } ;
329337 }
330-
331338 const range = document . createRange ( ) ;
332339 range . selectNodeContents ( code ) ;
333340 const lineRectsRaw = Array . from ( range . getClientRects ( ) ) . filter ( ( lineRect ) => lineRect . width > 1 && lineRect . height > 1 ) ;
334341 const lineRects = ( lineRectsRaw . length ? lineRectsRaw : [ code . getBoundingClientRect ( ) ] ) . map ( toRelative ) ;
335-
336- const codeMetrics = sampleRect ( toRelative ( code . getBoundingClientRect ( ) ) , 2 , 2 ) ;
337- const prefixMetrics = sampleRect ( toRelative ( prefix . getBoundingClientRect ( ) ) , 1 , 1 ) ;
338- const suffixMetrics = sampleRect ( toRelative ( suffix . getBoundingClientRect ( ) ) , 1 , 1 ) ;
339- const paragraphMetrics = sampleRect ( toRelative ( element . getBoundingClientRect ( ) ) , 1 , 1 ) ;
340-
341- const outsideSamples = Math . max ( 1 , paragraphMetrics . sampled - codeMetrics . sampled ) ;
342- const outsideTint = Math . max ( 0 , paragraphMetrics . tintRatio * paragraphMetrics . sampled - codeMetrics . tintRatio * codeMetrics . sampled ) ;
343- const outsideTintRatio = outsideTint / outsideSamples ;
342+ const codeRect = toRelative ( code . getBoundingClientRect ( ) ) ;
343+ const prefixRect = toRelative ( prefix . getBoundingClientRect ( ) ) ;
344+ const suffixRect = toRelative ( suffix . getBoundingClientRect ( ) ) ;
345+ const paragraphRect = toRelative ( element . getBoundingClientRect ( ) ) ;
346+ const codeMetrics = sampleRect ( codeRect , 2 , 2 ) ;
347+ const prefixMetrics = sampleRect ( prefixRect , 1 , 1 ) ;
348+ const suffixMetrics = sampleRect ( suffixRect , 1 , 1 ) ;
349+ const paragraphMetrics = sampleRect ( paragraphRect , 1 , 1 ) ;
350+ const outsideTintRatio = sampleOutsideTintRatio ( paragraphMetrics . canvasBox , codeMetrics . canvasBox ) ;
344351 const minStarts = lineRects . map ( ( lineRect ) => sampleRect ( lineRect , 1 , 1 ) . minInkXRatio ) ;
345352 const firstLineStart = minStarts [ 0 ] ?? 1 ;
346353 const lineStartDrift = minStarts . length > 1 ? Math . max ( ...minStarts ) - Math . min ( ...minStarts ) : 0 ;
347-
348354 const hasExpectedWrap = lineRects . length >= ( config . minCodeLines || 1 ) ;
349355 const ok =
350356 hasExpectedWrap &&
@@ -354,7 +360,6 @@ async function collectInlineWrapProbe(page) {
354360 outsideTintRatio <= 0.45 &&
355361 ( lineRects . length < 2 || firstLineStart <= 0.6 ) &&
356362 ( lineRects . length < 2 || lineStartDrift <= 0.55 ) ;
357-
358363 return {
359364 id : caseId ,
360365 ok,
@@ -366,18 +371,16 @@ async function collectInlineWrapProbe(page) {
366371 outsideTintRatio : Number ( outsideTintRatio . toFixed ( 5 ) ) ,
367372 firstLineStartRatio : Number ( firstLineStart . toFixed ( 5 ) ) ,
368373 maxLineStartDrift : Number ( lineStartDrift . toFixed ( 5 ) ) ,
369- paragraphCanvasBox : codeMetrics . canvasBox ,
374+ paragraphCanvasBox : paragraphMetrics . canvasBox ,
370375 } ;
371376 } ) ;
372-
373377 const wrapCasesOk = caseResults . every ( ( item ) => item . ok ) ;
374378 const ok = styleParityOk && wrapCasesOk ;
375379 const minCodeInkRatio = caseResults . length ? Math . min ( ...caseResults . map ( ( item ) => item . codeInkRatio || 0 ) ) : 0 ;
376380 const minPrefixInkRatio = caseResults . length ? Math . min ( ...caseResults . map ( ( item ) => item . prefixInkRatio || 0 ) ) : 0 ;
377381 const minSuffixInkRatio = caseResults . length ? Math . min ( ...caseResults . map ( ( item ) => item . suffixInkRatio || 0 ) ) : 0 ;
378382 const maxFirstLineStartRatio = caseResults . length ? Math . max ( ...caseResults . map ( ( item ) => item . firstLineStartRatio || 0 ) ) : 0 ;
379383 const maxLineStartDrift = caseResults . length ? Math . max ( ...caseResults . map ( ( item ) => item . maxLineStartDrift || 0 ) ) : 0 ;
380-
381384 return {
382385 ok,
383386 styleParityOk,
@@ -407,28 +410,23 @@ async function collectInlineWrapProbe(page) {
407410 }
408411 } ) ;
409412}
410-
411413async function run ( ) {
412414 fs . mkdirSync ( ARTIFACT_DIR , { recursive : true } ) ;
413415 const { server, port } = await startStaticServer ( ROOT ) ;
414416 const url = `http://127.0.0.1:${ port } /index.html` ;
415-
416417 try {
417418 const browser = await chromium . launch ( { headless : true } ) ;
418419 const context = await browser . newContext ( { viewport : { width : 1440 , height : 1000 } } ) ;
419420 const page = await context . newPage ( ) ;
420-
421421 await page . goto ( url , { waitUntil : "domcontentloaded" , timeout : 60000 } ) ;
422422 await page . waitForSelector ( "#cardHost" , { timeout : 25000 } ) ;
423423 await page . waitForFunction (
424424 ( ) => ! document . querySelector ( "#cardHost" ) ?. textContent ?. includes ( "Loading topic cards..." ) ,
425425 { timeout : 30000 }
426426 ) ;
427-
428427 if ( ( await page . locator ( "#splashOverlay:not(.hidden)" ) . count ( ) ) > 0 ) {
429428 await page . click ( "#getStartedBtn" , { timeout : 5000 } ) ;
430429 }
431-
432430 const selectableItems = page . locator ( "#cardHost [data-role='item-toggle']" ) ;
433431 if ( ( await selectableItems . count ( ) ) > 0 ) {
434432 await selectableItems . first ( ) . click ( { timeout : 5000 } ) ;
@@ -437,34 +435,28 @@ async function run() {
437435 await page . waitForTimeout ( 350 ) ;
438436 await page . click ( "#goToPreviewBtn" , { timeout : 5000 } ) ;
439437 await page . waitForSelector ( "#previewView.active" , { timeout : 10000 } ) ;
440-
441438 if ( ( await page . locator ( ".preview-card" ) . count ( ) ) < 1 ) {
442439 throw new Error ( "No preview cards available for export canvas guard." ) ;
443440 }
444-
445441 const probe = await collectCanvasProbe ( page ) ;
446442 const inlineWrapProbe = await collectInlineWrapProbe ( page ) ;
447443 const artifactPaths = [ ] ;
448-
449444 for ( const item of probe . pages ) {
450445 const artifactPath = path . join ( ARTIFACT_DIR , `export-canvas-page-${ item . page } .png` ) ;
451446 toFilePathFromDataUrl ( item . dataUrl , artifactPath ) ;
452447 artifactPaths . push ( artifactPath ) ;
453448 delete item . dataUrl ;
454449 item . artifactPath = artifactPath ;
455450 }
456-
457451 const inlineWrapArtifactPath = path . join ( ARTIFACT_DIR , "export-inline-wrap-probe.png" ) ;
458452 toFilePathFromDataUrl ( inlineWrapProbe . artifactDataUrl , inlineWrapArtifactPath ) ;
459453 delete inlineWrapProbe . artifactDataUrl ;
460454 inlineWrapProbe . artifactPath = inlineWrapArtifactPath ;
461-
462455 const ok =
463456 probe . pagesDetected >= 1 &&
464457 probe . minBBoxHeightRatio >= 0.55 &&
465458 probe . minBottomInkRatio >= 0.02 &&
466459 inlineWrapProbe . ok ;
467-
468460 await browser . close ( ) ;
469461 console . log (
470462 JSON . stringify (
@@ -489,7 +481,6 @@ async function run() {
489481 server . close ( ) ;
490482 }
491483}
492-
493484run ( ) . catch ( ( error ) => {
494485 console . error ( error . stack || error . message ) ;
495486 process . exit ( 1 ) ;
0 commit comments