diff --git a/figma-transformer/scripts/figma-fixture-matrix-quality.php b/figma-transformer/scripts/figma-fixture-matrix-quality.php index c5cdb0a2..072af31b 100644 --- a/figma-transformer/scripts/figma-fixture-matrix-quality.php +++ b/figma-transformer/scripts/figma-fixture-matrix-quality.php @@ -37,12 +37,22 @@ function matrix_quality_matrix(array $fixtures): array 'clipped_visual_node_count', 'fixed_width_over_desktop_uncovered_count', 'uncomposed_vector_child_nodes', + 'dom_horizontal_overflow_count', + 'dom_viewport_width_leak_count', + 'dom_huge_vertical_spacing_count', + 'dom_collapsed_box_count', + 'dom_offscreen_box_count', + 'dom_missing_node_id_box_count', + 'dom_capture_valid_count', + 'dom_capture_invalid_count', + 'dom_css_loaded_count', + 'dom_css_not_loaded_count', ); $totals = array_fill_keys($keys, 0); $qualityStatuses = array(); $signalCounts = array(); $riskBucketCounts = array_fill_keys(array('low', 'medium', 'high', 'critical', 'unknown'), 0); - $riskCategoryTotals = array_fill_keys(array('responsive_coverage', 'absolute_scaffolding', 'text_wrapping_leaks', 'image_geometry_fidelity', 'form_validity', 'route_coverage', 'unsupported_vectors'), 0); + $riskCategoryTotals = array_fill_keys(array('responsive_coverage', 'absolute_scaffolding', 'text_wrapping_leaks', 'image_geometry_fidelity', 'form_validity', 'route_coverage', 'unsupported_vectors', 'rendered_dom_boxes'), 0); $perFixtureReadiness = array(); $coverageNumerator = 0; $coverageDenominator = 0; @@ -54,8 +64,9 @@ function matrix_quality_matrix(array $fixtures): array continue; } $summary = is_array($fixture['quality_summary'] ?? null) ? $fixture['quality_summary'] : array(); + $domSummary = matrix_fixture_dom_box_quality_summary($fixture); foreach ( $keys as $key ) { - $totals[$key] += matrix_quality_summary_int($summary, $key); + $totals[$key] += matrix_quality_summary_int($summary, $key) + matrix_quality_summary_int($domSummary, $key); } $coverageNumerator += (int) ($summary['fixed_width_with_responsive_override_count'] ?? 0); $coverageDenominator += (int) ($summary['fixed_width_declaration_count'] ?? 0); @@ -85,6 +96,8 @@ function matrix_quality_matrix(array $fixtures): array 'readiness_score' => $readiness['readiness_score'] ?? null, 'visual_risk_bucket' => $readiness['visual_risk_bucket'] ?? 'unknown', 'route_coverage_ratio' => $readiness['route_coverage_ratio'] ?? null, + 'dom_css_loaded' => $domSummary['dom_css_loaded'] ?? null, + 'dom_capture_valid' => $domSummary['dom_capture_valid'] ?? null, 'risk_categories' => $readiness['risk_categories'] ?? array(), ); } @@ -165,6 +178,7 @@ function matrix_fixture_visual_readiness(array $fixture): array + ((float) ($summary['vector_decode_coverage_ratio'] ?? 1.0) < 1.0 ? 1 : 0), array('vector_placeholders', 'uncomposed_vector_child_nodes', 'vector_decode_coverage_ratio') ), + 'rendered_dom_boxes' => matrix_dom_box_risk_category(matrix_fixture_dom_box_quality_summary($fixture)), ); $riskPoints = 0; @@ -182,6 +196,271 @@ function matrix_fixture_visual_readiness(array $fixture): array ); } +/** + * @param array $fixture + * @return array + */ +function matrix_fixture_dom_box_quality_summary(array $fixture): array +{ + if ( is_array($fixture['dom_box_quality']['summary'] ?? null) ) { + return $fixture['dom_box_quality']['summary']; + } + + return array(); +} + +/** + * @return array|null + */ +function matrix_dom_box_quality_report(string $path): ?array +{ + if ( ! is_readable($path) ) { + return null; + } + + $report = json_decode((string) file_get_contents($path), true); + if ( ! is_array($report) ) { + return null; + } + + return matrix_analyze_dom_box_report($report, $path); +} + +/** + * @param array $report + * @return array + */ +function matrix_analyze_dom_box_report(array $report, string $sourcePath = ''): array +{ + $pageReports = array(); + $summary = matrix_dom_box_empty_summary(); + + foreach ( is_array($report['entrypoints'] ?? null) ? $report['entrypoints'] : array() as $entrypoint ) { + if ( ! is_array($entrypoint) ) { + continue; + } + + $pageReport = matrix_analyze_dom_box_entrypoint($entrypoint); + $pageReports[] = $pageReport; + foreach ( $summary as $key => $value ) { + if ( is_int($value) && isset($pageReport['summary'][$key]) && is_numeric($pageReport['summary'][$key]) ) { + $summary[$key] += (int) $pageReport['summary'][$key]; + } + } + } + + $summary['page_count'] = count($pageReports); + $summary['dom_capture_valid'] = $summary['page_count'] > 0 && 0 === $summary['dom_capture_invalid_count']; + $summary['dom_css_loaded'] = $summary['page_count'] > 0 && 0 === $summary['dom_css_not_loaded_count']; + $summary['risk_bucket'] = matrix_dom_box_risk_bucket((int) $summary['risk_score']); + + return array_filter(array( + 'schema' => 'blocks-engine/figma-transformer/dom-box-quality/v1', + 'source_path' => $sourcePath ?: null, + 'summary' => $summary, + 'pages' => $pageReports, + ), static fn (mixed $value): bool => null !== $value); +} + +/** + * @param array $entrypoint + * @return array + */ +function matrix_analyze_dom_box_entrypoint(array $entrypoint): array +{ + $viewport = is_array($entrypoint['viewport'] ?? null) ? $entrypoint['viewport'] : array(); + $viewportWidth = isset($viewport['width']) && is_numeric($viewport['width']) ? (float) $viewport['width'] : 1440.0; + $viewportHeight = isset($viewport['height']) && is_numeric($viewport['height']) ? (float) $viewport['height'] : 900.0; + $elements = is_array($entrypoint['elements'] ?? null) ? array_values($entrypoint['elements']) : array(); + $unidentified = is_array($entrypoint['unidentified_elements'] ?? null) ? array_values($entrypoint['unidentified_elements']) : array(); + $summary = matrix_dom_box_empty_summary(); + $findings = array(); + $previousBottom = null; + $domCssLoaded = true === ($entrypoint['dom_css_loaded'] ?? null); + $domCaptureValid = true === ($entrypoint['dom_capture_valid'] ?? null) && $domCssLoaded; + + if ( $domCaptureValid ) { + $summary['dom_capture_valid_count'] = 1; + } else { + $summary['dom_capture_invalid_count'] = 1; + } + if ( $domCssLoaded ) { + $summary['dom_css_loaded_count'] = 1; + } else { + $summary['dom_css_not_loaded_count'] = 1; + } + + if ( ! $domCaptureValid ) { + $summary['risk_bucket'] = 'low'; + $summary['dom_capture_valid'] = false; + $summary['dom_css_loaded'] = $domCssLoaded; + return array_filter(array( + 'page_path' => isset($entrypoint['page_path']) && is_scalar($entrypoint['page_path']) ? (string) $entrypoint['page_path'] : '', + 'viewport' => $viewport, + 'summary' => $summary, + 'stylesheet_status' => is_array($entrypoint['stylesheet_status'] ?? null) ? $entrypoint['stylesheet_status'] : null, + 'findings' => array(matrix_dom_box_finding('dom_capture_invalid', array(), array('dom_css_loaded' => $domCssLoaded))), + ), static fn (mixed $value): bool => null !== $value); + } + + usort($elements, static function (mixed $a, mixed $b): int { + $aRect = is_array($a) && is_array($a['boundingClientRect'] ?? null) ? $a['boundingClientRect'] : array(); + $bRect = is_array($b) && is_array($b['boundingClientRect'] ?? null) ? $b['boundingClientRect'] : array(); + return ((float) ($aRect['top'] ?? 0)) <=> ((float) ($bRect['top'] ?? 0)); + }); + + foreach ( $elements as $element ) { + if ( ! is_array($element) ) { + continue; + } + + $summary['dom_element_count']++; + $rect = is_array($element['boundingClientRect'] ?? null) ? $element['boundingClientRect'] : array(); + $left = matrix_dom_box_number($rect, 'left', matrix_dom_box_number($rect, 'x', 0.0)); + $right = matrix_dom_box_number($rect, 'right', $left + matrix_dom_box_number($rect, 'width', 0.0)); + $top = matrix_dom_box_number($rect, 'top', matrix_dom_box_number($rect, 'y', 0.0)); + $bottom = matrix_dom_box_number($rect, 'bottom', $top + matrix_dom_box_number($rect, 'height', 0.0)); + $width = matrix_dom_box_number($rect, 'width', max(0.0, $right - $left)); + $height = matrix_dom_box_number($rect, 'height', max(0.0, $bottom - $top)); + $textMetrics = is_array($element['text_metrics'] ?? null) ? $element['text_metrics'] : array(); + $scrollWidth = matrix_dom_box_number($textMetrics, 'scroll_width', 0.0); + $clientWidth = matrix_dom_box_number($textMetrics, 'client_width', 0.0); + $nodeId = isset($element['node_id']) && is_scalar($element['node_id']) ? trim((string) $element['node_id']) : ''; + $node = matrix_dom_box_node_summary($element); + + if ( $right > $viewportWidth + 1.0 || $left < -1.0 || ($scrollWidth > 0.0 && $clientWidth > 0.0 && $scrollWidth > $clientWidth + 1.0) ) { + $summary['dom_horizontal_overflow_count']++; + $findings[] = matrix_dom_box_finding('dom_horizontal_overflow', $node, array('left' => $left, 'right' => $right, 'viewport_width' => $viewportWidth, 'scroll_width' => $scrollWidth, 'client_width' => $clientWidth)); + } + + if ( $width > $viewportWidth + 1.0 || $right > $viewportWidth + 24.0 ) { + $summary['dom_viewport_width_leak_count']++; + $findings[] = matrix_dom_box_finding('dom_viewport_width_leak', $node, array('width' => $width, 'right' => $right, 'viewport_width' => $viewportWidth)); + } + + if ( $width <= 1.0 || $height <= 1.0 ) { + $summary['dom_collapsed_box_count']++; + $findings[] = matrix_dom_box_finding('dom_collapsed_box', $node, array('width' => $width, 'height' => $height)); + } + + if ( $left < -1.0 || $right < -1.0 || $top < -1.0 || $left > $viewportWidth + 1.0 ) { + $summary['dom_offscreen_box_count']++; + $findings[] = matrix_dom_box_finding('dom_offscreen_box', $node, array('left' => $left, 'right' => $right, 'top' => $top, 'viewport_width' => $viewportWidth)); + } + + if ( '' === $nodeId ) { + $summary['dom_missing_node_id_box_count']++; + $findings[] = matrix_dom_box_finding('dom_missing_node_id_box', $node, array()); + } + + if ( null !== $previousBottom && $top - $previousBottom > $viewportHeight ) { + $summary['dom_huge_vertical_spacing_count']++; + $findings[] = matrix_dom_box_finding('dom_huge_vertical_spacing', $node, array('gap' => round($top - $previousBottom, 3), 'viewport_height' => $viewportHeight)); + } + $previousBottom = null === $previousBottom ? $bottom : max($previousBottom, $bottom); + } + + foreach ( $unidentified as $element ) { + if ( ! is_array($element) ) { + continue; + } + $summary['dom_missing_node_id_box_count']++; + $findings[] = matrix_dom_box_finding('dom_missing_node_id_box', matrix_dom_box_node_summary($element), array('source' => 'unidentified_elements')); + } + + $summary['risk_score'] = $summary['dom_horizontal_overflow_count'] + + $summary['dom_viewport_width_leak_count'] + + $summary['dom_huge_vertical_spacing_count'] + + $summary['dom_collapsed_box_count'] + + $summary['dom_offscreen_box_count'] + + $summary['dom_missing_node_id_box_count']; + $summary['risk_bucket'] = matrix_dom_box_risk_bucket((int) $summary['risk_score']); + $summary['dom_capture_valid'] = true; + $summary['dom_css_loaded'] = true; + + return array( + 'page_path' => isset($entrypoint['page_path']) && is_scalar($entrypoint['page_path']) ? (string) $entrypoint['page_path'] : '', + 'viewport' => $viewport, + 'summary' => $summary, + 'stylesheet_status' => is_array($entrypoint['stylesheet_status'] ?? null) ? $entrypoint['stylesheet_status'] : null, + 'findings' => array_slice($findings, 0, 50), + ); +} + +/** + * @return array + */ +function matrix_dom_box_empty_summary(): array +{ + return array( + 'page_count' => 0, + 'dom_element_count' => 0, + 'dom_horizontal_overflow_count' => 0, + 'dom_viewport_width_leak_count' => 0, + 'dom_huge_vertical_spacing_count' => 0, + 'dom_collapsed_box_count' => 0, + 'dom_offscreen_box_count' => 0, + 'dom_missing_node_id_box_count' => 0, + 'dom_capture_valid_count' => 0, + 'dom_capture_invalid_count' => 0, + 'dom_css_loaded_count' => 0, + 'dom_css_not_loaded_count' => 0, + 'risk_score' => 0, + 'risk_bucket' => 'low', + ); +} + +/** + * @param array $values + */ +function matrix_dom_box_number(array $values, string $key, float $fallback): float +{ + return isset($values[$key]) && is_numeric($values[$key]) ? (float) $values[$key] : $fallback; +} + +/** + * @param array $element + * @return array + */ +function matrix_dom_box_node_summary(array $element): array +{ + return array_filter(array( + 'id' => isset($element['node_id']) && is_scalar($element['node_id']) ? (string) $element['node_id'] : null, + 'name' => isset($element['node_name']) && is_scalar($element['node_name']) ? (string) $element['node_name'] : null, + 'selector' => isset($element['selector']) && is_scalar($element['selector']) ? (string) $element['selector'] : null, + 'tag' => isset($element['tag']) && is_scalar($element['tag']) ? (string) $element['tag'] : null, + ), static fn (mixed $value): bool => null !== $value && '' !== $value); +} + +/** + * @param array $node + * @param array $metrics + * @return array + */ +function matrix_dom_box_finding(string $code, array $node, array $metrics): array +{ + return array_filter(array( + 'code' => $code, + 'node' => $node, + 'metrics' => $metrics, + ), static fn (mixed $value): bool => array() !== $value); +} + +function matrix_dom_box_risk_bucket(int $riskScore): string +{ + if ( $riskScore >= 25 ) { + return 'critical'; + } + if ( $riskScore >= 10 ) { + return 'high'; + } + if ( $riskScore >= 1 ) { + return 'medium'; + } + + return 'low'; +} + function matrix_visual_risk_bucket(int $readinessScore): string { if ( $readinessScore >= 90 ) { @@ -209,6 +488,28 @@ function matrix_risk_category(int $count, array $signals): array ); } +/** + * @param array $domSummary + * @return array{count: int, signals: array} + */ +function matrix_dom_box_risk_category(array $domSummary): array +{ + $signals = array('dom_horizontal_overflow_count', 'dom_viewport_width_leak_count', 'dom_huge_vertical_spacing_count', 'dom_collapsed_box_count', 'dom_offscreen_box_count', 'dom_missing_node_id_box_count'); + if ( false === ($domSummary['dom_capture_valid'] ?? true) ) { + return matrix_risk_category(0, array_merge(array('dom_capture_invalid'), $signals)); + } + + return matrix_risk_category( + matrix_quality_summary_int($domSummary, 'dom_horizontal_overflow_count') + + matrix_quality_summary_int($domSummary, 'dom_viewport_width_leak_count') + + matrix_quality_summary_int($domSummary, 'dom_huge_vertical_spacing_count') + + matrix_quality_summary_int($domSummary, 'dom_collapsed_box_count') + + matrix_quality_summary_int($domSummary, 'dom_offscreen_box_count') + + matrix_quality_summary_int($domSummary, 'dom_missing_node_id_box_count'), + $signals + ); +} + /** * @param array $summary */ diff --git a/figma-transformer/scripts/figma-fixture-matrix.php b/figma-transformer/scripts/figma-fixture-matrix.php index bff8f853..368d7d40 100755 --- a/figma-transformer/scripts/figma-fixture-matrix.php +++ b/figma-transformer/scripts/figma-fixture-matrix.php @@ -226,6 +226,13 @@ $record['status'] = 'dom_box_capture_failed'; } if ( 0 === $captureExitCode && is_file($domBoxesPath) ) { + $domBoxQualityPath = $fixtureOutputDir . '/dom-box-quality.json'; + $domBoxQuality = matrix_dom_box_quality_report($domBoxesPath); + if ( is_array($domBoxQuality) ) { + file_put_contents($domBoxQualityPath, json_encode($domBoxQuality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + $record['dom_box_quality'] = $domBoxQuality; + $record['dom_box_capture']['quality_report_path'] = $domBoxQualityPath; + } $capturedEvidenceOptions = matrix_merge_evidence_templates($evidenceOptions, array( 'dom_boxes_path' => $domBoxesPath, )); diff --git a/figma-transformer/tests/contract/FixtureMatrixContract.php b/figma-transformer/tests/contract/FixtureMatrixContract.php index cc2df1f7..7bf7b06f 100644 --- a/figma-transformer/tests/contract/FixtureMatrixContract.php +++ b/figma-transformer/tests/contract/FixtureMatrixContract.php @@ -254,6 +254,85 @@ function blocks_engine_figma_transformer_run_fixture_matrix_contract(callable $a ), ), )); + $domBoxQuality = matrix_analyze_dom_box_report(array( + 'schema' => 'homeboy/static-artifact-dom-boxes/v1', + 'entrypoints' => array( + array( + 'page_path' => '/index.html', + 'viewport' => array('width' => 1440, 'height' => 900, 'device_scale_factor' => 1), + 'dom_css_loaded' => true, + 'dom_capture_valid' => true, + 'stylesheet_status' => array('body_margin' => '0px', 'body_margin_reset' => true), + 'elements' => array( + array( + 'node_id' => 'frame:root', + 'node_name' => 'Root', + 'selector' => 'main[data-figma-node-id="frame:root"]', + 'tag' => 'main', + 'boundingClientRect' => array('left' => 0, 'right' => 1488, 'top' => 0, 'bottom' => 100, 'width' => 1488, 'height' => 100), + 'text_metrics' => array('scroll_width' => 1500, 'client_width' => 1440), + ), + array( + 'node_id' => 'frame:collapsed-image', + 'node_name' => 'Collapsed image', + 'selector' => 'img[data-figma-node-id="frame:collapsed-image"]', + 'tag' => 'img', + 'boundingClientRect' => array('left' => 16, 'right' => 16, 'top' => 112, 'bottom' => 112, 'width' => 0, 'height' => 0), + ), + array( + 'node_id' => 'frame:offscreen', + 'node_name' => 'Offscreen', + 'selector' => 'section[data-figma-node-id="frame:offscreen"]', + 'tag' => 'section', + 'boundingClientRect' => array('left' => -42, 'right' => 20, 'top' => 130, 'bottom' => 220, 'width' => 62, 'height' => 90), + ), + array( + 'node_id' => 'frame:later', + 'node_name' => 'Later section', + 'selector' => 'section[data-figma-node-id="frame:later"]', + 'tag' => 'section', + 'boundingClientRect' => array('left' => 20, 'right' => 300, 'top' => 1300, 'bottom' => 1400, 'width' => 280, 'height' => 100), + ), + array( + 'node_id' => '', + 'node_name' => 'Missing id', + 'selector' => 'div', + 'tag' => 'div', + 'boundingClientRect' => array('left' => 20, 'right' => 100, 'top' => 1420, 'bottom' => 1500, 'width' => 80, 'height' => 80), + ), + ), + 'unidentified_elements' => array( + array( + 'selector' => 'aside', + 'tag' => 'aside', + 'boundingClientRect' => array('left' => 20, 'right' => 100, 'top' => 1520, 'bottom' => 1600, 'width' => 80, 'height' => 80), + ), + ), + ), + ), + ), '/tmp/dom-boxes.json'); + $invalidDomBoxQuality = matrix_analyze_dom_box_report(array( + 'schema' => 'homeboy/static-artifact-dom-boxes/v1', + 'entrypoints' => array( + array( + 'page_path' => '/index.html', + 'viewport' => array('width' => 1440, 'height' => 900, 'device_scale_factor' => 1), + 'dom_css_loaded' => false, + 'dom_capture_valid' => false, + 'stylesheet_status' => array('body_margin' => '8px', 'body_margin_reset' => false), + 'elements' => array( + array( + 'node_id' => 'frame:root', + 'node_name' => 'Root', + 'selector' => 'main[data-figma-node-id="frame:root"]', + 'tag' => 'main', + 'boundingClientRect' => array('left' => 0, 'right' => 1488, 'top' => 0, 'bottom' => 100, 'width' => 1488, 'height' => 100), + 'text_metrics' => array('scroll_width' => 1500, 'client_width' => 1440), + ), + ), + ), + ), + ), '/tmp/dom-boxes-invalid.json'); $matrixQualitySummary = matrix_quality_matrix(array( array( 'id' => 'ready-fixture', @@ -289,6 +368,15 @@ function blocks_engine_figma_transformer_run_fixture_matrix_contract(callable $a array('code' => 'responsive_fixed_width_without_override'), array('code' => 'fallback_prone_html_islands'), )), + 'dom_box_quality' => $domBoxQuality, + ), + )); + $invalidDomBoxMatrixSummary = matrix_quality_matrix(array( + array( + 'id' => 'invalid-dom-capture-fixture', + 'status' => 'completed', + 'selected_frame_ids' => array('frame:home'), + 'dom_box_quality' => $invalidDomBoxQuality, ), )); @@ -375,11 +463,33 @@ function blocks_engine_figma_transformer_run_fixture_matrix_contract(callable $a $assert(0.5 === ($matrixVisualReadiness['route_coverage_ratio'] ?? null), 'fixture-matrix-visual-readiness-route-coverage'); $assert(4 === ($matrixVisualReadiness['risk_categories']['responsive_coverage']['count'] ?? null), 'fixture-matrix-visual-readiness-responsive-risk-count'); $assert(7 === ($matrixVisualReadiness['risk_categories']['text_wrapping_leaks']['count'] ?? null), 'fixture-matrix-visual-readiness-text-risk-count'); + $assert('blocks-engine/figma-transformer/dom-box-quality/v1' === ($domBoxQuality['schema'] ?? null), 'fixture-matrix-dom-box-quality-schema'); + $assert(2 === ($domBoxQuality['summary']['dom_horizontal_overflow_count'] ?? null), 'fixture-matrix-dom-box-quality-horizontal-overflow'); + $assert(true === ($domBoxQuality['summary']['dom_css_loaded'] ?? null), 'fixture-matrix-dom-box-quality-css-loaded'); + $assert(true === ($domBoxQuality['summary']['dom_capture_valid'] ?? null), 'fixture-matrix-dom-box-quality-capture-valid'); + $assert(1 === ($domBoxQuality['summary']['dom_viewport_width_leak_count'] ?? null), 'fixture-matrix-dom-box-quality-viewport-width-leak'); + $assert(1 === ($domBoxQuality['summary']['dom_huge_vertical_spacing_count'] ?? null), 'fixture-matrix-dom-box-quality-huge-vertical-spacing'); + $assert(1 === ($domBoxQuality['summary']['dom_collapsed_box_count'] ?? null), 'fixture-matrix-dom-box-quality-collapsed-box'); + $assert(1 === ($domBoxQuality['summary']['dom_offscreen_box_count'] ?? null), 'fixture-matrix-dom-box-quality-offscreen-box'); + $assert(2 === ($domBoxQuality['summary']['dom_missing_node_id_box_count'] ?? null), 'fixture-matrix-dom-box-quality-missing-node-id-boxes'); + $assert(false === ($invalidDomBoxQuality['summary']['dom_css_loaded'] ?? null), 'fixture-matrix-invalid-dom-box-css-not-loaded'); + $assert(false === ($invalidDomBoxQuality['summary']['dom_capture_valid'] ?? null), 'fixture-matrix-invalid-dom-box-capture-invalid'); + $assert(1 === ($invalidDomBoxQuality['summary']['dom_capture_invalid_count'] ?? null), 'fixture-matrix-invalid-dom-box-invalid-count'); + $assert(0 === ($invalidDomBoxQuality['summary']['dom_horizontal_overflow_count'] ?? null), 'fixture-matrix-invalid-dom-box-does-not-score-horizontal-overflow'); + $assert('dom_capture_invalid' === ($invalidDomBoxQuality['pages'][0]['findings'][0]['code'] ?? null), 'fixture-matrix-invalid-dom-box-finding'); $assert('blocks-engine/figma-transformer/fixture-matrix-quality/v1' === ($matrixQualitySummary['schema'] ?? null), 'fixture-matrix-quality-schema'); $assert(0.625 === ($matrixQualitySummary['effective_responsive_coverage_ratio'] ?? null), 'fixture-matrix-quality-responsive-coverage-ratio'); $assert(0.75 === ($matrixQualitySummary['route_coverage_ratio'] ?? null), 'fixture-matrix-quality-route-coverage-ratio'); $assert(2 === ($matrixQualitySummary['totals']['breakpoint_override_leak_count'] ?? null), 'fixture-matrix-quality-nested-html-artifact-total'); + $assert(2 === ($matrixQualitySummary['totals']['dom_horizontal_overflow_count'] ?? null), 'fixture-matrix-quality-dom-horizontal-overflow-total'); + $assert(2 === ($matrixQualitySummary['totals']['dom_missing_node_id_box_count'] ?? null), 'fixture-matrix-quality-dom-missing-node-id-total'); $assert(3 === ($matrixQualitySummary['risk_category_totals']['responsive_coverage'] ?? null), 'fixture-matrix-quality-risk-category-total'); + $assert(8 === ($matrixQualitySummary['risk_category_totals']['rendered_dom_boxes'] ?? null), 'fixture-matrix-quality-rendered-dom-risk-category-total'); + $assert(1 === ($invalidDomBoxMatrixSummary['totals']['dom_capture_invalid_count'] ?? null), 'fixture-matrix-quality-invalid-dom-capture-total'); + $assert(false === ($invalidDomBoxMatrixSummary['per_fixture_readiness'][0]['dom_capture_valid'] ?? null), 'fixture-matrix-quality-invalid-dom-capture-readiness-flag'); + $assert(false === ($invalidDomBoxMatrixSummary['per_fixture_readiness'][0]['dom_css_loaded'] ?? null), 'fixture-matrix-quality-invalid-dom-css-loaded-flag'); + $assert(0 === ($invalidDomBoxMatrixSummary['risk_category_totals']['rendered_dom_boxes'] ?? null), 'fixture-matrix-quality-invalid-dom-not-scored-as-rendered-risk'); + $assert(in_array('dom_capture_invalid', $invalidDomBoxMatrixSummary['per_fixture_readiness'][0]['risk_categories']['rendered_dom_boxes']['signals'] ?? array(), true), 'fixture-matrix-quality-invalid-dom-risk-signal'); $assert(2 === count($matrixQualitySummary['per_fixture_readiness'] ?? array()), 'fixture-matrix-quality-per-fixture-readiness'); $assert(is_array($matrixAliasSummary), 'fixture-matrix-alias-json-summary'); $assert('/opt/homeboy-alias' === ($matrixAliasSummary['homeboy_command'] ?? null), 'fixture-matrix-homeboy-bin-alias'); diff --git a/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs index 4591604f..f998d809 100755 --- a/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs +++ b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs @@ -89,7 +89,7 @@ async function main() { page_path: pagePath, page_url: page.url(), viewport: DEFAULT_VIEWPORT, - elements: await extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs), + ...(await extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs)), }); await page.close(); } @@ -242,6 +242,18 @@ async function extractElements(page, pagePath, textSampleLimit, nodeIdAttr, node return Object.fromEntries(computedStyleProperties.map((property) => [property, computed.getPropertyValue(property)])); } + function stylesheetStatus() { + const bodyStyle = document.body ? window.getComputedStyle(document.body) : null; + const bodyMargin = bodyStyle ? bodyStyle.getPropertyValue('margin') : ''; + const linkStylesheets = Array.from(document.querySelectorAll('link[rel~="stylesheet"]')); + return { + stylesheet_count: document.styleSheets.length, + stylesheet_link_count: linkStylesheets.length, + body_margin: bodyMargin, + body_margin_reset: bodyMargin === '0px', + }; + } + function estimateLineCount(element, computedStyle, textLength) { if (textLength === 0 || element.clientHeight <= 0) { return 0; @@ -332,7 +344,7 @@ async function extractElements(page, pagePath, textSampleLimit, nodeIdAttr, node }; } - return Array.from(document.querySelectorAll(`[${idAttr}]`)).map((element) => { + function serializeElement(element) { const rect = element.getBoundingClientRect(); const nodeId = element.getAttribute(idAttr) || ''; const nodeName = readNodeName(element); @@ -352,7 +364,38 @@ async function extractElements(page, pagePath, textSampleLimit, nodeIdAttr, node asset_state: serializeAssetState(element, computedStyle), visibility: serializeVisibility(element, rect, computedStyle), }; - }); + } + + function serializeUnidentifiedElement(element) { + const rect = element.getBoundingClientRect(); + return { + page_path: currentPagePath, + selector: element.tagName.toLowerCase(), + tag: element.tagName.toLowerCase(), + text_sample: normalizeText(element.textContent), + boundingClientRect: serializeBoundingClientRect(rect), + }; + } + + const elements = Array.from(document.querySelectorAll(`[${idAttr}]`)).map(serializeElement); + const unidentifiedElements = Array.from(document.body?.querySelectorAll('*') ?? []) + .filter((element) => !element.hasAttribute(idAttr)) + .filter((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 1 && rect.height > 1 && style.display !== 'none' && style.visibility !== 'hidden'; + }) + .slice(0, 50) + .map(serializeUnidentifiedElement); + + const cssStatus = stylesheetStatus(); + return { + dom_css_loaded: cssStatus.body_margin_reset, + dom_capture_valid: cssStatus.body_margin_reset, + stylesheet_status: cssStatus, + elements, + unidentified_elements: unidentifiedElements, + }; }, { pagePath, limit: textSampleLimit, diff --git a/php-transformer/tools/visual-parity/tests/smoke.mjs b/php-transformer/tools/visual-parity/tests/smoke.mjs index 7ca42eb0..9e866041 100644 --- a/php-transformer/tools/visual-parity/tests/smoke.mjs +++ b/php-transformer/tools/visual-parity/tests/smoke.mjs @@ -36,6 +36,7 @@ assert(report.source.snapshots[0].probes[0].matches[0].padding.top === '10px', ' assert(report.comparison[0].probes[0].count_delta === 0, 'compares source and target counts'); await writeFile(domFixture, `
Hello world
`); await writeFile(domFixtureGeneric, `
Hello generic
`); const server = createServer(async (request, response) => { @@ -88,6 +90,9 @@ try { HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT: '20', }); assert(domReport.entrypoints.length === 1, 'DOM provider captures one entrypoint'); + assert(domReport.entrypoints[0].dom_css_loaded === true, 'DOM provider reports CSS loaded when reset is applied'); + assert(domReport.entrypoints[0].dom_capture_valid === true, 'DOM provider reports capture valid when CSS reset is applied'); + assert(domReport.entrypoints[0].stylesheet_status.body_margin === '0px', 'DOM provider captures body margin reset status'); assert(domReport.entrypoints[0].elements[0].node_id === '12:34', 'DOM provider captures node id'); assert(domReport.entrypoints[0].elements[0].node_name === 'Hero', 'DOM provider captures node name'); assert(domReport.entrypoints[0].elements[0].selector === 'main[data-figma-node-id="12:34"]', 'DOM provider keys selector off default figma attribute'); @@ -106,6 +111,7 @@ try { assert(domReport.entrypoints[0].elements[0].asset_state.descendants[0].complete === true, 'DOM provider captures image complete state'); assert(domReport.entrypoints[0].elements[0].visibility.visible === true, 'DOM provider captures visible state'); assert(domReport.entrypoints[0].elements[0].visibility.clipped === true, 'DOM provider captures clipped-ish overflow state'); + assert(domReport.entrypoints[0].unidentified_elements.some((element) => element.tag === 'img'), 'DOM provider reports visible elements without node ids'); const genericEnvReport = await runJson(process.execPath, [path.join(root, 'bin/dom-box-provider.mjs')], root, { ...process.env,