diff --git a/includes/class-static-site-importer-page-materializer.php b/includes/class-static-site-importer-page-materializer.php index a88b449..6b75890 100644 --- a/includes/class-static-site-importer-page-materializer.php +++ b/includes/class-static-site-importer-page-materializer.php @@ -163,19 +163,25 @@ private static function dedupe_template_part_shell_blocks( string $content, arra $footer_parts = self::template_part_write_sequences( $template_part_writes, 'footer' ); $remove_start = 0; $remove_end = count( $top_level ); + $matched_header_part = ''; + $matched_footer_part = ''; - foreach ( $header_parts as $part_blocks ) { + foreach ( $header_parts as $part ) { + $part_blocks = $part['blocks']; $count = count( $part_blocks ); if ( $count > 0 && self::block_sequence_matches( array_slice( $top_level, 0, $count ), $part_blocks ) ) { $remove_start = max( $remove_start, $count ); + $matched_header_part = $part['path']; break; } } - foreach ( $footer_parts as $part_blocks ) { + foreach ( $footer_parts as $part ) { + $part_blocks = $part['blocks']; $count = count( $part_blocks ); if ( $count > 0 && $remove_start <= count( $top_level ) - $count && self::block_sequence_matches( array_slice( $top_level, -$count ), $part_blocks ) ) { $remove_end = min( $remove_end, count( $top_level ) - $count ); + $matched_footer_part = $part['path']; break; } } @@ -189,15 +195,21 @@ private static function dedupe_template_part_shell_blocks( string $content, arra $deduped = trim( substr( $content, $start_offset, $end_offset - $start_offset ) ); $diagnostics[] = array( - 'type' => 'template_part_shell_deduped', - 'source' => 'static-site-importer/page-materializer', - 'source_path' => $source_path, - 'reason' => 'matching_header_footer_template_parts', - 'removed' => array( + 'type' => 'template_part_shell_deduped', + 'source' => 'static-site-importer/page-materializer', + 'source_path' => $source_path, + 'reason' => 'matching_header_footer_template_parts', + 'removed_header_blocks' => $remove_start, + 'removed_footer_blocks' => count( $top_level ) - $remove_end, + 'matched_header_template_part' => $matched_header_part, + 'matched_footer_template_part' => $matched_footer_part, + 'preserved_local_header_count' => self::html_tag_count( $deduped, 'header' ), + 'preserved_local_footer_count' => self::html_tag_count( $deduped, 'footer' ), + 'removed' => array( 'leading_blocks' => $remove_start, 'trailing_blocks' => count( $top_level ) - $remove_end, ), - 'message' => 'Page body blocks matching generated header/footer template parts were removed to avoid duplicate global shell rendering.', + 'message' => 'Page body blocks matching generated header/footer template parts were removed to avoid duplicate global shell rendering.', ); return $deduped; @@ -262,7 +274,7 @@ private static function top_level_serialized_blocks( string $markup ): array { * * @param array $template_part_writes Generated template part writes. * @param string $area Template part area. - * @return array> + * @return array}> */ private static function template_part_write_sequences( array $template_part_writes, string $area ): array { $sequences = array(); @@ -274,13 +286,43 @@ private static function template_part_write_sequences( array $template_part_writ $blocks = self::top_level_serialized_blocks( (string) $markup ); if ( ! empty( $blocks ) ) { - $sequences[] = $blocks; + $sequences[] = array( + 'path' => self::template_part_report_path( (string) $path ), + 'blocks' => $blocks, + ); } } return $sequences; } + /** + * Convert a generated template-part write path into a report-stable relative path. + * + * @param string $path Template part write path. + * @return string + */ + private static function template_part_report_path( string $path ): string { + $normalized = str_replace( '\\', '/', $path ); + $parts_pos = strrpos( $normalized, '/parts/' ); + if ( false !== $parts_pos ) { + return ltrim( substr( $normalized, $parts_pos + 1 ), '/' ); + } + + return basename( $normalized ); + } + + /** + * Count preserved HTML tags in the post-dedupe page body. + * + * @param string $markup Serialized block markup. + * @param string $tag Tag name. + * @return int + */ + private static function html_tag_count( string $markup, string $tag ): int { + return preg_match_all( '/<\s*' . preg_quote( $tag, '/' ) . '\b/i', $markup ) ?: 0; + } + /** * Check whether two top-level block sequences are the same rendered shell. * @@ -1126,7 +1168,7 @@ private static function fallback_html_to_native_blocks( string $html, string $fa * @return string|null Serialized block markup, or null when unsupported. */ private static function safe_html_fragment_to_blocks( string $html ): ?string { - if ( preg_match( '/<\s*(?:script|style|iframe|canvas|svg|select|textarea)\b/i', $html ) ) { + if ( preg_match( '/<\s*(?:script|style|iframe|canvas)\b/i', $html ) ) { return null; } @@ -1196,6 +1238,9 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string } if ( in_array( $tag, array( 'a', 'button' ), true ) ) { + if ( 'button' === $tag && self::is_runtime_control_element( $node ) ) { + return null; + } $content = self::safe_inline_html( $node ); if ( null === $content || '' === trim( wp_strip_all_tags( $content ) ) ) { return null; @@ -1285,7 +1330,7 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string foreach ( iterator_to_array( $node->childNodes ) as $child ) { $child_markup = self::safe_dom_node_to_block_markup( $child ); if ( null === $child_markup ) { - return null; + $child_markup = self::fallback_markup_for_dom_node( $child ); } $children .= $child_markup; } @@ -1303,6 +1348,56 @@ private static function safe_dom_node_to_block_markup( DOMNode $node ): ?string return null; } + /** + * Preserve an unsupported child as a bounded fallback inside an otherwise native container. + * + * @param DOMNode $node DOM node. + * @return string + */ + private static function fallback_markup_for_dom_node( DOMNode $node ): string { + $html = self::dom_node_outer_html( $node ); + return '' === trim( $html ) ? '' : self::serialized_block_markup( 'core/html', array( 'content' => $html ), $html ); + } + + /** + * Serialize a DOM node back to its fragment HTML. + * + * @param DOMNode $node DOM node. + * @return string + */ + private static function dom_node_outer_html( DOMNode $node ): string { + if ( $node instanceof DOMText ) { + return (string) $node->textContent; + } + + if ( $node->ownerDocument instanceof DOMDocument ) { + return (string) $node->ownerDocument->saveHTML( $node ); + } + + return ''; + } + + /** + * Detect source controls whose behavior depends on commerce/runtime JavaScript. + * + * @param DOMElement $element Element. + * @return bool + */ + private static function is_runtime_control_element( DOMElement $element ): bool { + $class = strtolower( trim( preg_replace( '/\s+/', ' ', $element->getAttribute( 'class' ) ) ?? '' ) ); + if ( '' !== $class && preg_match( '/(^|[-_\s])(?:add-to-cart|qty|quantity|cart)([-_\s]|$)/', $class ) ) { + return true; + } + + foreach ( iterator_to_array( $element->attributes ) as $attribute ) { + if ( $attribute instanceof DOMAttr && preg_match( '/^data-(?:dir|cart|quantity|qty|product|product-id)$/', strtolower( $attribute->name ) ) ) { + return true; + } + } + + return false; + } + /** * Extract className block attrs from an element. * @@ -1367,6 +1462,9 @@ private static function safe_inline_html( DOMElement $element ): ?string { } $tag = strtolower( $child->tagName ); + if ( 'svg' === $tag && 'true' === strtolower( trim( $child->getAttribute( 'aria-hidden' ) ) ) ) { + continue; + } if ( ! in_array( $tag, array( 'a', 'br', 'strong', 'b', 'em', 'i', 'span', 'small', 'mark', 'sub', 'sup' ), true ) || $child->hasAttribute( 'style' ) ) { return null; } diff --git a/includes/class-static-site-importer-report-diagnostics.php b/includes/class-static-site-importer-report-diagnostics.php index 475e526..85f48ab 100644 --- a/includes/class-static-site-importer-report-diagnostics.php +++ b/includes/class-static-site-importer-report-diagnostics.php @@ -4144,6 +4144,12 @@ private static function compact_import_report_diagnostics( array $diagnostics ): 'stage', 'reason', 'message', + 'removed_header_blocks', + 'removed_footer_blocks', + 'matched_header_template_part', + 'matched_footer_template_part', + 'preserved_local_header_count', + 'preserved_local_footer_count', 'tag_name', 'element', 'html_excerpt', diff --git a/lib/fixture-matrix/collectors/visual-parity.mjs b/lib/fixture-matrix/collectors/visual-parity.mjs index 1a8c39a..c396b0d 100644 --- a/lib/fixture-matrix/collectors/visual-parity.mjs +++ b/lib/fixture-matrix/collectors/visual-parity.mjs @@ -295,6 +295,13 @@ function normalizeVisualParityComparison(value, options = {}) { return null; } const compareOptions = objectValue(obj.options || summary.options || visualCompare.options || comparison.options); + const viewport = normalizeViewport(firstObject([ + obj.viewport, + summary.viewport, + visualCompare.viewport, + comparison.viewport, + compareOptions.viewport, + ])); const overlapPixels = firstNumber([summary.overlap_pixels, summary.overlapPixels, visualCompare.overlapPixels, visualCompare.overlap_pixels, comparison.overlapPixels, comparison.overlap_pixels, obj.overlap_pixels, obj.overlapPixels]); const dimensionDeltaPixels = firstNumber([summary.dimension_delta_pixels, summary.dimensionDeltaPixels, visualCompare.dimensionDeltaPixels, visualCompare.dimension_delta_pixels, comparison.dimensionDeltaPixels, comparison.dimension_delta_pixels, obj.dimension_delta_pixels, obj.dimensionDeltaPixels]); const safeMismatch = Number.isFinite(mismatchPixels) ? mismatchPixels : 0; @@ -343,6 +350,8 @@ function normalizeVisualParityComparison(value, options = {}) { has_overlap_signal: hasOverlapSignal, dimension_delta_pixels: safeDimensionDelta, dimension_mismatch: dimensionMismatch, + viewport, + full_page: firstDefined([obj.full_page, obj.fullPage, summary.full_page, summary.fullPage, visualCompare.full_page, visualCompare.fullPage, comparison.full_page, comparison.fullPage, compareOptions.full_page, compareOptions.fullPage]), pixelmatch_threshold: firstNumber([compareOptions.threshold, compareOptions.pixelmatchThreshold, compareOptions.pixelmatch_threshold]), source_path: firstString([sourceObject.path, sourceObject.url, obj.source_path, obj.sourcePath]), source_screenshot: firstRef([artifacts.source_screenshot, artifactSlots.source_screenshot, files.sourceScreenshot, obj.source_screenshot, obj.sourceScreenshot]), @@ -359,6 +368,32 @@ function normalizeVisualParityComparison(value, options = {}) { return classification ? { ...withAlignment, ...classification } : withAlignment; } +function firstObject(values) { + for (const value of values) { + const obj = objectValue(value); + if (Object.keys(obj).length > 0) { + return obj; + } + } + return {}; +} + +function firstDefined(values) { + for (const value of values) { + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return undefined; +} + +function normalizeViewport(value) { + const obj = objectValue(value); + const width = finiteNumber(obj.width, 0); + const height = finiteNumber(obj.height, 0); + return width || height ? compactObject({ width, height }) : undefined; +} + function normalizeMismatchRegions(values) { return values.map((value) => { const row = objectValue(value); @@ -604,6 +639,8 @@ export function collectVisualParityArtifacts(payload, options = {}) { overlap_pixels: comparison.overlap_pixels, dimension_delta_pixels: comparison.dimension_delta_pixels, dimension_mismatch: comparison.dimension_mismatch, + viewport: comparison.viewport, + full_page: comparison.full_page, }), ...(comparison.visual_diff_regions?.length ? { visual_diff_regions: comparison.visual_diff_regions } : {}), ...(comparison.visual_diff_cause_summary ? { visual_diff_cause_summary: comparison.visual_diff_cause_summary } : {}), diff --git a/lib/fixture-matrix/gutenberg-incompatibility-registry.mjs b/lib/fixture-matrix/gutenberg-incompatibility-registry.mjs index 8dc811b..a5cab06 100644 --- a/lib/fixture-matrix/gutenberg-incompatibility-registry.mjs +++ b/lib/fixture-matrix/gutenberg-incompatibility-registry.mjs @@ -13,7 +13,7 @@ export const DEFAULT_CUSTOM_BLOCK_CANDIDATE_FIXTURE_THRESHOLD = 2; const PATTERNS = [ pattern('static-form', 'Static newsletter/contact/search-style form markup that requires fields, submission semantics, validation, and response behavior.', 'core_html', 'WordPress core has no generic form block with field schema, submit handling, validation state, and submission response behavior.', 'custom-block-candidate', true, /= threshold) { classification = 'custom-block-candidate'; } else if (row.base_classification === 'custom-block-candidate') { diff --git a/lib/fixture-matrix/visual-evidence-report.mjs b/lib/fixture-matrix/visual-evidence-report.mjs index cb1c627..16b53c9 100644 --- a/lib/fixture-matrix/visual-evidence-report.mjs +++ b/lib/fixture-matrix/visual-evidence-report.mjs @@ -289,6 +289,12 @@ function viewportEvidenceRows({ artifacts, artifactRefs }) { rows.push(compactObject({ phase: entry.phase, width, height, message: entry.message })); } }; + const metrics = objectValue(artifacts.metrics || artifacts.comparison); + pushViewport({ + phase: 'visual-compare', + viewport: metrics.viewport, + message: metrics.viewport ? `visual compare viewport${metrics.full_page === true ? ' (full-page capture)' : ''}` : '', + }); for (const diagnostic of normalizeArray(artifacts.capture_diagnostics || artifacts.captureDiagnostics || artifacts.visual_explanation?.capture_diagnostics || artifacts.visual_explanation?.captureDiagnostics)) { pushViewport(diagnostic); } diff --git a/tests/smoke-safe-html-fallback-reducer.php b/tests/smoke-safe-html-fallback-reducer.php index 3bf5a44..f0a4999 100644 --- a/tests/smoke-safe-html-fallback-reducer.php +++ b/tests/smoke-safe-html-fallback-reducer.php @@ -134,6 +134,27 @@ function wp_strip_all_tags( string $value ): string { $assert( str_contains( $output, 'Search posts' ), 'search-placeholder-preserved' ); $assert( str_contains( $output, '
' ), 'unsupported-form-fallback-preserved' ); +$mixed_contact_html = '

Send a Message

'; +$mixed_contact_input = '' . $mixed_contact_html . ''; +$mixed_contact_output = $method->invoke( null, $mixed_contact_input ); +$mixed_contact_after = $count_blocks( $mixed_contact_output ); + +$assert( 1 === ( $count_blocks( $mixed_contact_input )['core/html'] ?? 0 ), 'mixed-contact-before-single-large-html' ); +$assert( 1 === ( $mixed_contact_after['core/html'] ?? 0 ), 'mixed-contact-after-single-bounded-form-html', print_r( $mixed_contact_after, true ) ); +$assert( 3 <= ( $mixed_contact_after['core/group'] ?? 0 ), 'mixed-contact-static-layout-groups-converted', print_r( $mixed_contact_after, true ) ); +$assert( in_array( 'core/heading', array_keys( $mixed_contact_after ), true ), 'mixed-contact-heading-converted' ); +$assert( str_contains( $mixed_contact_output, 'booking@example.com' ), 'mixed-contact-link-text-preserved' ); +$assert( str_contains( $mixed_contact_output, '
' ), 'mixed-contact-runtime-form-preserved' ); + +$cart_control_html = '

EP Tee

Washed black tee.

1
'; +$cart_control_input = '' . $cart_control_html . ''; +$cart_control_output = $method->invoke( null, $cart_control_input ); +$cart_control_after = $count_blocks( $cart_control_output ); + +$assert( 3 === ( $cart_control_after['core/html'] ?? 0 ), 'cart-controls-remain-bounded-html-islands', print_r( $cart_control_after, true ) ); +$assert( 0 === ( $cart_control_after['core/button'] ?? 0 ), 'cart-controls-not-faked-as-core-buttons', print_r( $cart_control_after, true ) ); +$assert( str_contains( $cart_control_output, 'class="add-to-cart"' ), 'cart-add-control-preserved' ); + if ( $failures ) { fwrite( STDERR, implode( "\n", $failures ) . "\n" ); exit( 1 ); diff --git a/tests/smoke-template-part-shell-dedupe.php b/tests/smoke-template-part-shell-dedupe.php index de3fcf3..77249bb 100644 --- a/tests/smoke-template-part-shell-dedupe.php +++ b/tests/smoke-template-part-shell-dedupe.php @@ -60,6 +60,8 @@ function wp_strip_all_tags( string $value ): string { $header = ''; $footer = '

Global Footer

'; $body = '

Article body remains.

'; +$article_header = '

Article Header

'; +$article_footer = '

Article Footer

'; $page = Static_Site_Importer_Source_Page::from_materialization_plan_page( array( @@ -89,9 +91,37 @@ function wp_strip_all_tags( string $value ): string { $assert( 'template_part_shell_deduped' === ( $artifacts['diagnostics'][0]['type'] ?? '' ), 'dedupe-diagnostic-emitted' ); $assert( 1 === ( $artifacts['diagnostics'][0]['removed']['leading_blocks'] ?? 0 ), 'diagnostic-leading-count' ); $assert( 1 === ( $artifacts['diagnostics'][0]['removed']['trailing_blocks'] ?? 0 ), 'diagnostic-trailing-count' ); +$assert( 1 === ( $artifacts['diagnostics'][0]['removed_header_blocks'] ?? 0 ), 'diagnostic-removed-header-blocks' ); +$assert( 1 === ( $artifacts['diagnostics'][0]['removed_footer_blocks'] ?? 0 ), 'diagnostic-removed-footer-blocks' ); +$assert( 'parts/header.html' === ( $artifacts['diagnostics'][0]['matched_header_template_part'] ?? '' ), 'diagnostic-header-template-part-match' ); +$assert( 'parts/footer.html' === ( $artifacts['diagnostics'][0]['matched_footer_template_part'] ?? '' ), 'diagnostic-footer-template-part-match' ); +$assert( 0 === ( $artifacts['diagnostics'][0]['preserved_local_header_count'] ?? -1 ), 'diagnostic-no-local-header-after-global-dedupe' ); +$assert( 0 === ( $artifacts['diagnostics'][0]['preserved_local_footer_count'] ?? -1 ), 'diagnostic-no-local-footer-after-global-dedupe' ); + +$local_shell_page = Static_Site_Importer_Source_Page::from_materialization_plan_page( + array( + 'source_path' => 'local-shell.html', + 'title' => 'Local Shell', + 'block_markup' => $header . "\n" . $article_header . "\n" . $body . "\n" . $article_footer . "\n" . $footer, + ) +); + +$local_shell_artifacts = $local_shell_page instanceof Static_Site_Importer_Source_Page ? Static_Site_Importer_Page_Materializer::page_artifacts( + array( 'local-shell.html' => $local_shell_page ), + 'ssi-theme', + array(), + array(), + $template_part_writes +) : array(); +$local_shell_content = (string) ( $local_shell_artifacts['contents']['local-shell.html'] ?? '' ); + +$assert( ! str_contains( $local_shell_content, 'Global Header' ), 'local-shell-global-header-removed' ); +$assert( ! str_contains( $local_shell_content, 'Global Footer' ), 'local-shell-global-footer-removed' ); +$assert( str_contains( $local_shell_content, 'Article Header' ), 'local-shell-local-header-preserved' ); +$assert( str_contains( $local_shell_content, 'Article Footer' ), 'local-shell-local-footer-preserved' ); +$assert( 1 === ( $local_shell_artifacts['diagnostics'][0]['preserved_local_header_count'] ?? 0 ), 'diagnostic-local-header-count-preserved' ); +$assert( 1 === ( $local_shell_artifacts['diagnostics'][0]['preserved_local_footer_count'] ?? 0 ), 'diagnostic-local-footer-count-preserved' ); -$article_header = '

Article Header

'; -$article_footer = '

Article Footer

'; $local_page = Static_Site_Importer_Source_Page::from_materialization_plan_page( array( 'source_path' => 'article.html', diff --git a/tools/fixture-matrix.test.mjs b/tools/fixture-matrix.test.mjs index f4086bc..9e0ba78 100644 --- a/tools/fixture-matrix.test.mjs +++ b/tools/fixture-matrix.test.mjs @@ -239,6 +239,7 @@ test('gutenberg incompatibility registry separates provider-materializable forms matrix_id: 'provider-materializable-patterns', fixtures: [ { fixture_id: 'provider-fixture' }, + { fixture_id: 'provider-fixture-2' }, { fixture_id: 'core-gap-fixture' }, ], findings: [ @@ -263,6 +264,19 @@ test('gutenberg incompatibility registry separates provider-materializable forms selector: 'button.add-to-cart', source_snippet: '', }, + { + fixture_id: 'provider-fixture-2', + kind: 'woocommerce_present', + source_path: 'commerce.dependencies.woocommerce', + reason: 'WooCommerce is active; commerce-bearing import will seed products.', + }, + { + fixture_id: 'provider-fixture-2', + kind: 'core_html_block', + observed_block_name: 'core/html', + selector: 'button.add-to-cart', + source_snippet: '', + }, { fixture_id: 'core-gap-fixture', kind: 'core_html_block', @@ -278,9 +292,13 @@ test('gutenberg incompatibility registry separates provider-materializable forms const patterns = Object.fromEntries(registry.patterns.map((row) => [row.pattern_key, row])); assert.equal(patterns['static-form'].limitation_type, 'provider_materializable'); + assert.equal(patterns['static-form'].classification, 'convertible'); assert.equal(patterns['static-form'].provider_materialized_by.jetpack, 1); assert.equal(patterns['js-commerce-controls'].limitation_type, 'provider_materializable'); - assert.equal(patterns['js-commerce-controls'].provider_materialized_by.woocommerce, 1); + assert.equal(patterns['js-commerce-controls'].classification, 'convertible'); + assert.equal(patterns['js-commerce-controls'].provider_materialized_by.woocommerce, 2); + assert.equal(patterns['js-commerce-controls'].fixture_count, 2); + assert.equal(registry.summary.custom_block_candidate_count, 0); assert.deepEqual(providerDecision.provider_materializable_patterns, ['js-commerce-controls', 'static-form']); assert.deepEqual(providerDecision.gutenberg_gap_patterns, []); assert.deepEqual(coreGapDecision.gutenberg_gap_patterns, []); @@ -302,6 +320,7 @@ test('gutenberg incompatibility registry separates provider-materializable forms const noProviderPatterns = Object.fromEntries(noProviderRegistry.patterns.map((row) => [row.pattern_key, row])); assert.equal(noProviderPatterns['static-form'].limitation_type, 'real_gutenberg_gap'); + assert.equal(noProviderPatterns['static-form'].classification, 'convertible'); }); test('gutenberg incompatibility registry separates fixture decision axes', () => { @@ -469,6 +488,65 @@ test('gutenberg incompatibility registry attributes nested svg to the outer fall assert.equal(byKey['inline-svg-filter-gradient'].fixtures[0], 'coffee'); }); +test('gutenberg incompatibility registry does not treat handled contact links or provider forms as contact layout transformer gaps', () => { + const registry = buildGutenbergIncompatibilityRegistry({ + matrix_id: 'contact-link-provider-separation', + fixtures: [ + { + fixture_id: 'artist', + editor_validation: { total: 12 }, + editor_canvas: { status: 'visible' }, + visual_parity_artifacts: { screenshot: 'artist.png' }, + }, + { + fixture_id: 'artist-form', + editor_validation: { total: 12 }, + editor_canvas: { status: 'visible' }, + visual_parity_artifacts: { screenshot: 'artist-form.png' }, + }, + ], + findings: [ + { + fixture_id: 'artist', + kind: 'link_affordance_preserved', + reason_code: 'native_link', + selector: 'a.contact-email', + source_snippet: 'booking@example.comCall', + observed_block_name: 'core/paragraph', + }, + { + fixture_id: 'artist', + kind: 'link_affordance_preserved', + reason_code: 'native_link', + selector: '.social-links a', + source_snippet: '', + observed_block_name: 'core/buttons', + }, + { + fixture_id: 'artist-form', + kind: 'unsupported_html_fallback', + observed_block_name: 'jetpack/contact-form', + diagnostic_code: 'html_form_fallback', + reason_code: 'form_requires_runtime', + pattern_family: 'interactive_form', + selector: 'form.contact-form', + source_snippet: '
', + }, + ], + }); + const byKey = Object.fromEntries(registry.patterns.map((row) => [row.pattern_key, row])); + const decisions = Object.fromEntries(registry.fixture_decisions.map((row) => [row.fixture_id, row])); + + assert.equal(byKey['contact-layout'], undefined); + assert.equal(byKey['static-form'].limitation_type, 'provider_materializable'); + assert.deepEqual(decisions.artist.transformer_gap_patterns, []); + assert.deepEqual(decisions.artist.provider_materializable_patterns, []); + assert.equal(decisions.artist.native_editability_status, 'native_editable'); + assert.deepEqual(decisions['artist-form'].transformer_gap_patterns, []); + assert.deepEqual(decisions['artist-form'].provider_materializable_patterns, ['static-form']); + assert.equal(decisions['artist-form'].native_editability_status, 'html_islands_or_transformer_gap'); +}); + test('gutenberg incompatibility registry ranks tracked custom-block candidates before visual-only evidence', () => { const registry = buildGutenbergIncompatibilityRegistry({ matrix_id: 'tracked-candidate-ranking', @@ -4883,7 +4961,12 @@ test('visual-compare artifacts collected from fixture files gate the matrix when mkdirSync(fixtureDirectory, { recursive: true }); writeFileSync(path.join(fixtureDirectory, 'visual-diff.json'), JSON.stringify({ schema: 'wp-codebox/visual-compare/v1', - comparison: { mismatchPixels: 700000, totalPixels: 2048000, dimensionMismatch: false }, + comparison: { + mismatchPixels: 700000, + totalPixels: 2048000, + dimensionMismatch: false, + options: { viewport: { width: 1280, height: 720 }, fullPage: true }, + }, files: { sourceScreenshot: 'files/browser/visual-compare/source.png', candidateScreenshot: 'files/browser/visual-compare/candidate.png', @@ -4901,6 +4984,8 @@ test('visual-compare artifacts collected from fixture files gate the matrix when assert.equal(gated.fixtures[0].visual_parity_artifacts.schema, 'static-site-importer/visual-parity-artifacts/v1'); assert.equal(gated.fixtures[0].visual_parity_artifacts.artifacts.diff_screenshot.status, 'captured'); assert.equal(gated.fixtures[0].visual_parity_artifacts.metrics.mismatch_pixels, 700000); + assert.deepEqual(gated.fixtures[0].visual_parity_artifacts.metrics.viewport, { width: 1280, height: 720 }); + assert.equal(gated.fixtures[0].visual_parity_artifacts.metrics.full_page, true); assert.equal(finding.artifact_refs.find((ref) => ref.artifact_id === 'diff_screenshot')?.path, 'files/browser/visual-compare/diff.png'); const exemplar = gated.summary.top_pattern_families.find((family) => family.kind === VISUAL_PARITY_MISMATCH_KIND)?.exemplars[0]; assert.equal(exemplar.artifact_refs.find((ref) => ref.artifact_id === 'diff_screenshot')?.path, 'files/browser/visual-compare/diff.png'); @@ -4913,6 +4998,47 @@ test('visual-compare artifacts collected from fixture files gate the matrix when assert.equal(captured.fixtures[0].status, 'passed'); }); +test('visual evidence report infers viewport evidence from visual-compare metrics', () => { + const outputDirectory = mkdtempSync(path.join(tmpdir(), 'ssi-visual-metric-viewport-')); + const matrix = createFixtureMatrix({ fixture_root: fixtureRoot, id: 'visual-metric-viewport-test' }); + const fixtureDirectory = path.join(outputDirectory, 'simple-site'); + mkdirSync(path.join(fixtureDirectory, 'source'), { recursive: true }); + writeFileSync(path.join(fixtureDirectory, 'artifact.json'), '{}'); + writeFileSync(path.join(fixtureDirectory, 'source', 'index.html'), '

Simple SSI Fixture

'); + + const result = normalizeFixtureMatrixResult({ + matrix, + results: [ + { + fixture_id: 'simple-site', + status: 'passed', + visual_parity_artifacts: { + metrics: { + mismatch_pixels: 0, + total_pixels: 1000, + mismatch_ratio: 0, + viewport: { width: 390, height: 844 }, + full_page: true, + }, + artifacts: { + imported_screenshot: { status: 'captured', ref: { path: 'files/browser/visual-compare/candidate-mobile.png', kind: 'browser-visual-candidate-screenshot' } }, + }, + }, + }, + ], + }); + + const report = buildVisualParityEvidenceReport({ outputDirectory, matrix, result }); + const viewport = report.fixtures[0].evidence.viewports.rows[0]; + + assert.equal(report.summary.viewport_evidence_fixture_count, 1); + assert.equal(report.summary.mobile_viewport_fixture_count, 1); + assert.equal(viewport.phase, 'visual-compare'); + assert.equal(viewport.width, 390); + assert.equal(viewport.height, 844); + assert.equal(report.fixtures[0].risk.reasons.includes('missing mobile viewport evidence'), false); +}); + test('visual-compare PNGs are copied to the bench artifact root and registered', () => { const outputDirectory = mkdtempSync(path.join(tmpdir(), 'ssi-visual-persisted-output-')); const codeboxArtifactsDirectory = mkdtempSync(path.join(tmpdir(), 'ssi-visual-codebox-artifacts-'));