diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index f8846b851..0f44a9fa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -89,6 +89,410 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; +const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js"; +const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => { + const messageType = 'genarrative.local-preview-size.v1'; + const maxContentProbes = 512; + let ready = document.readyState === 'complete'; + let frame = 0; + let lastPublishedSize = ''; + const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() }; + const previousRootLayoutSample = {}; + const previousBodyLayoutSample = {}; + const rootIndependentLayout = { width: false, height: false }; + const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } }; + const resetLayoutCouplingSamples = (axis) => { + elementLayoutSamples[axis] = new WeakMap(); + previousRootLayoutSample[axis] = undefined; + previousBodyLayoutSample[axis] = undefined; + }; + const resolveViewportCoupling = ( + extent, viewport, previousExtent, previousViewport, previousCoupled, + ) => { + if (extent === undefined || previousExtent === undefined || previousViewport === undefined) { + return false; + } + const viewportDelta = viewport - previousViewport; + if (Math.abs(viewportDelta) < 1) { + return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false; + } + const extentDelta = extent - previousExtent; + return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0; + }; + const previewStyleExtent = (value) => { + const extent = Number.parseFloat(value || ''); + return Number.isFinite(extent) ? extent : undefined; + }; + const previewTypedStyleValue = (element, property) => { + try { + return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase(); + } catch { + return undefined; + } + }; + const previewTypedMinimumCreatesExtent = (value) => { + if (value === undefined || value === 'auto' || value === 'none') return false; + const extent = Number.parseFloat(value); + return Number.isFinite(extent) ? extent > 0 : true; + }; + const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => { + if (!root || !body) return false; + const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1; + const sizeProperty = axis === 'width' ? 'width' : 'height'; + const minimumProperty = axis === 'width' ? 'min-width' : 'min-height'; + const typedSize = previewTypedStyleValue(root, sizeProperty); + const typedMinimum = previewTypedStyleValue(root, minimumProperty); + if (typedSize !== undefined || typedMinimum !== undefined) { + rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto') + || previewTypedMinimumCreatesExtent(typedMinimum); + } else if (!sameExtent) { + rootIndependentLayout[axis] = true; + } + return sameExtent && !rootIndependentLayout[axis]; + }; + const previewStyleUsesViewport = (value) => ( + (value || '').includes('%') + || /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '') + ); + const previewViewportStyleBaseline = (value, viewport) => { + const normalized = (value || '').trim().toLowerCase(); + const amount = Number.parseFloat(normalized); + if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined; + if (normalized.endsWith('%')) return viewport * amount / 100; + if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) { + return viewport * amount / 100; + } + return undefined; + }; + const sampleAxisViewportCoupling = ( + edge, sizeStyle, minimumStyle, viewport, previous, + ) => { + const size = previewStyleExtent(sizeStyle); + const minimum = previewStyleExtent(minimumStyle); + const sizeUsesViewport = previewStyleUsesViewport(sizeStyle); + const minimumUsesViewport = previewStyleUsesViewport(minimumStyle); + const edgeCoupled = resolveViewportCoupling( + edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false, + ); + const sizeCoupled = sizeUsesViewport || resolveViewportCoupling( + size, viewport, previous?.size, previous?.viewport, + previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false, + ); + const minimumCoupled = minimumUsesViewport || resolveViewportCoupling( + minimum, viewport, previous?.minimum, previous?.viewport, + previous?.minimumUsesViewport && !minimumUsesViewport + ? false + : previous?.minimumCoupled || false, + ); + const edgeBaseline = edgeCoupled + ? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge + : undefined; + const sizeBaseline = sizeCoupled + ? previous?.sizeBaseline + ?? previous?.baseline + ?? previous?.size + ?? previewViewportStyleBaseline(sizeStyle, viewport) + ?? size + : undefined; + const minimumBaseline = minimumCoupled + ? previous?.minimumBaseline + ?? previous?.baseline + ?? previous?.minimum + ?? previewViewportStyleBaseline(minimumStyle, viewport) + ?? minimum + : undefined; + const baseline = Math.max( + 0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0, + ) || undefined; + return { + edge, + size, + minimum, + viewport, + edgeCoupled, + sizeCoupled, + minimumCoupled, + edgeBaseline, + sizeBaseline, + minimumBaseline, + sizeUsesViewport, + minimumUsesViewport, + baseline, + coupled: edgeCoupled || sizeCoupled || minimumCoupled, + }; + }; + const sampleElementViewportCoupling = ( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ) => { + const style = window.getComputedStyle(element); + const width = sampleAxisViewportCoupling( + widthExtent, style.width, style.minWidth, viewportWidth, + elementLayoutSamples.width.get(element), + ); + const height = sampleAxisViewportCoupling( + heightExtent, style.height, style.minHeight, viewportHeight, + elementLayoutSamples.height.get(element), + ); + elementLayoutSamples.width.set(element, width); + elementLayoutSamples.height.set(element, height); + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const sampleDocumentViewportCoupling = ( + previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight, + ) => { + const width = sampleAxisViewportCoupling( + widthExtent, style?.width, style?.minWidth, viewportWidth, + previousLayoutSample.width, + ); + const height = sampleAxisViewportCoupling( + heightExtent, style?.height, style?.minHeight, viewportHeight, + previousLayoutSample.height, + ); + previousLayoutSample.width = width; + previousLayoutSample.height = height; + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const measureContentBounds = (body, viewportWidth, viewportHeight) => { + if (!body) return { width: 0, height: 0, truncated: false }; + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + let width = 0; + let height = 0; + let widthBaseline = 0; + let heightBaseline = 0; + let visited = 0; + let element = walker.nextNode(); + while (element && visited < maxContentProbes) { + visited += 1; + const rect = element.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + const widthExtent = rect.right + window.scrollX; + const heightExtent = rect.bottom + window.scrollY; + const coupling = sampleElementViewportCoupling( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ); + if (!coupling.width) width = Math.max(width, widthExtent); + if (!coupling.height) height = Math.max(height, heightExtent); + widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0); + heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0); + } + element = walker.nextNode(); + } + return { + width: Math.ceil(Math.max(0, width)), + height: Math.ceil(Math.max(0, height)), + widthBaseline: Math.ceil(Math.max(0, widthBaseline)), + heightBaseline: Math.ceil(Math.max(0, heightBaseline)), + truncated: element !== null, + }; + }; + const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => ( + truncated || !bodyCoupled + ? Math.max(bodyExtent, contentExtent) + : contentExtent + ); + const resolveContentExtent = ( + state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated, + coupledBaseline, + ) => { + const viewportChanged = state.viewport !== undefined + && Math.abs(viewportExtent - state.viewport) >= 1; + const hostAppliedPrevious = state.resolved !== undefined + && Math.abs(viewportExtent - state.resolved) <= 1; + const nativeReset = viewportChanged && !hostAppliedPrevious; + if (nativeReset) { + state.responsive = false; + state.responsiveFloor = undefined; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + resetLayoutCouplingSamples(state.axis); + } + const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport; + const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll; + const responsiveScroll = !nativeReset + && hostAppliedPrevious + && Math.abs(viewportDelta) >= 1 + && Math.abs(scrollDelta) >= 1 + && viewportDelta * scrollDelta > 0; + const candidateContradicted = !state.responsive + && (state.responsiveCandidateCount || 0) > 0 + && ( + (viewportChanged && hostAppliedPrevious && !responsiveScroll) + || ( + Math.abs(scrollDelta) >= 1 + && (state.responsiveCandidateDirection || 0) * scrollDelta < 0 + ) + ); + if (responsiveScroll && !state.responsive) { + const candidateFloor = truncated + ? Math.max(coupledBaseline || 0, state.resolved || 1) + : coupledBaseline || state.resolved; + if (truncated) { + state.responsive = true; + state.responsiveFloor = candidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } else { + if ((state.responsiveCandidateCount || 0) === 0) { + state.responsiveCandidateDirection = Math.sign(viewportDelta); + } + state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1; + state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor; + if (state.responsiveCandidateCount >= 2) { + state.responsive = true; + state.responsiveFloor = state.responsiveCandidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + } + } else if (candidateContradicted) { + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + const resolved = state.responsive + ? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent) + : (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent); + if (state.responsive && truncated) { + state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved); + } + state.viewport = viewportExtent; + state.scroll = scrollExtent; + state.resolved = resolved; + return resolved; + }; + const publish = () => { + frame = 0; + if (!ready || window.parent === window) return; + const root = document.documentElement; + const body = document.body; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const rootRect = root?.getBoundingClientRect(); + const rootStyle = root ? window.getComputedStyle(root) : null; + const bodyRect = body?.getBoundingClientRect(); + const bodyStyle = body ? window.getComputedStyle(body) : null; + const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginRight || '0') || 0); + const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0); + const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin); + const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin); + const intrinsicRootWidth = Math.ceil(Math.max( + root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX, + )); + const intrinsicRootHeight = Math.ceil(Math.max( + root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY, + )); + const rootWidthMirrorsBody = rootAxisMirrorsBody( + 'width', root, body, intrinsicRootWidth, intrinsicBodyWidth, + ); + const rootHeightMirrorsBody = rootAxisMirrorsBody( + 'height', root, body, intrinsicRootHeight, intrinsicBodyHeight, + ); + const rootCoupling = sampleDocumentViewportCoupling( + previousRootLayoutSample, + intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight, + ); + const bodyCoupling = sampleDocumentViewportCoupling( + previousBodyLayoutSample, + intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight, + ); + const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight); + const coupledWidthBaseline = Math.max( + rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0, + bodyCoupling.widthBaseline || 0, + contentBounds.widthBaseline || 0, + ); + const coupledHeightBaseline = Math.max( + rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0, + bodyCoupling.heightBaseline || 0, + contentBounds.heightBaseline || 0, + ); + const intrinsicBodyContentWidth = resolveIntrinsicExtent( + intrinsicBodyWidth, bodyCoupling.width, + contentBounds.width, contentBounds.truncated, + ); + const intrinsicBodyContentHeight = resolveIntrinsicExtent( + intrinsicBodyHeight, bodyCoupling.height, + contentBounds.height, contentBounds.truncated, + ); + const intrinsicContentWidth = Math.max( + coupledWidthBaseline, + resolveIntrinsicExtent( + intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody, + intrinsicBodyContentWidth, contentBounds.truncated, + ), + ); + const intrinsicContentHeight = Math.max( + coupledHeightBaseline, + resolveIntrinsicExtent( + intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody, + intrinsicBodyContentHeight, contentBounds.truncated, + ), + ); + const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)); + const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)); + const size = { + contentWidth: Math.max( + 1, resolveContentExtent( + contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth, + contentBounds.width, contentBounds.truncated, coupledWidthBaseline, + ), + ), + contentHeight: Math.max( + 1, resolveContentExtent( + contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight, + contentBounds.height, contentBounds.truncated, coupledHeightBaseline, + ), + ), + viewportWidth, + viewportHeight, + }; + const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`; + if (sizeKey === lastPublishedSize) return; + lastPublishedSize = sizeKey; + window.parent.postMessage({ type: messageType, ...size }, '*'); + }; + const schedule = () => { + if (frame === 0) frame = window.requestAnimationFrame(publish); + }; + const activate = () => { + ready = true; + const style = document.createElement('style'); + style.dataset.genarrativePreviewFit = 'true'; + style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}'; + (document.head || document.documentElement).appendChild(style); + schedule(); + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(document.documentElement); + if (document.body) resizeObserver.observe(document.body); + } + document.addEventListener('load', schedule, true); + document.fonts?.ready.then(schedule); + window.addEventListener('resize', schedule, { passive: true }); + window.setInterval(() => { + if (!document.hidden) schedule(); + }, 500); + }; + if (ready) activate(); + else window.addEventListener('load', activate, { once: true }); +})();"#; +const PREVIEW_FIT_BRIDGE_TAG: &str = + r#""#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PreviewListenerAcceptDisposition { @@ -560,6 +964,20 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) ); } + if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) { + let body = if is_head { + Vec::new() + } else { + PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec() + }; + return http_response( + "200 OK", + "text/javascript; charset=utf-8", + &body, + PREVIEW_FIT_BRIDGE_SCRIPT.len(), + ); + } + let file_path = match resolve_preview_path(root, url_path) { Ok(path) => path, Err(_) => { @@ -574,11 +992,419 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) return http_response("404 Not Found", "text/plain", body, b"not found".len()); } }; + let body = if content_type(&file_path).starts_with("text/html") { + inject_preview_fit_bridge(body) + } else { + body + }; let content_length = body.len(); let body = if is_head { Vec::new() } else { body }; http_response("200 OK", content_type(&file_path), &body, content_length) } +fn inject_preview_fit_bridge(body: Vec) -> Vec { + let html = match String::from_utf8(body) { + Ok(html) => html, + Err(error) => return error.into_bytes(), + }; + let scan = scan_preview_fit_bridge_html(&html); + if scan.has_bridge_script { + return html.into_bytes(); + } + let insertion = scan.body_end.or(scan.html_end).unwrap_or(scan.safe_append); + let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len()); + output.push_str(&html[..insertion]); + output.push_str(PREVIEW_FIT_BRIDGE_TAG); + output.push_str(&html[insertion..]); + output.into_bytes() +} + +#[derive(Clone, Copy, Debug)] +struct PreviewFitBridgeHtmlScan { + has_bridge_script: bool, + body_end: Option, + html_end: Option, + safe_append: usize, +} + +#[derive(Clone, Copy, Debug)] +struct PreviewHtmlTag { + start: usize, + name_start: usize, + name_end: usize, + end: usize, + closing: bool, +} + +fn scan_preview_fit_bridge_html(html: &str) -> PreviewFitBridgeHtmlScan { + let bytes = html.as_bytes(); + let mut scan = PreviewFitBridgeHtmlScan { + has_bridge_script: false, + body_end: None, + html_end: None, + safe_append: bytes.len(), + }; + let mut cursor = 0; + let mut template_depth = 0_u32; + let mut outer_template_start = None; + + while let Some(relative_start) = bytes[cursor..].iter().position(|byte| *byte == b'<') { + let tag_start = cursor + relative_start; + if bytes[tag_start..].starts_with(b"") { + return Some(cursor + 3); + } + if bytes.get(cursor..cursor + 4) == Some(b"--!>") { + return Some(cursor + 4); + } + cursor += 1; + } + None +} + +fn preview_html_doctype_at(bytes: &[u8], start: usize) -> bool { + let name_start = start + 2; + let name_end = name_start + b"doctype".len(); + bytes + .get(name_start..name_end) + .is_some_and(|name| name.eq_ignore_ascii_case(b"doctype")) +} + +fn preview_html_doctype_end(bytes: &[u8], start: usize) -> Option { + bytes + .get(start..)? + .iter() + .position(|byte| *byte == b'>') + .map(|relative_end| start + relative_end + 1) +} + +fn preview_html_tag_at(bytes: &[u8], start: usize) -> Option { + if bytes.get(start) != Some(&b'<') { + return None; + } + let mut cursor = start + 1; + let closing = bytes.get(cursor) == Some(&b'/'); + if closing { + cursor += 1; + } + let name_start = cursor; + if !bytes.get(cursor).is_some_and(u8::is_ascii_alphabetic) { + return None; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| !preview_html_space(*byte) && !matches!(*byte, b'/' | b'>')) + { + cursor += 1; + } + let name_end = cursor; + let end = preview_html_markup_end(bytes, cursor)?; + Some(PreviewHtmlTag { + start, + name_start, + name_end, + end, + closing, + }) +} + +fn preview_html_markup_end(bytes: &[u8], mut cursor: usize) -> Option { + let mut quote = None; + while let Some(byte) = bytes.get(cursor).copied() { + if let Some(expected) = quote { + if byte == expected { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == b'>' { + return Some(cursor + 1); + } + cursor += 1; + } + None +} + +fn preview_html_tag_name_is(bytes: &[u8], tag: PreviewHtmlTag, expected: &[u8]) -> bool { + bytes[tag.name_start..tag.name_end].eq_ignore_ascii_case(expected) +} + +fn preview_html_raw_text_name(bytes: &[u8], tag: PreviewHtmlTag) -> Option<&'static [u8]> { + [ + b"script".as_slice(), + b"style".as_slice(), + b"title".as_slice(), + b"textarea".as_slice(), + b"xmp".as_slice(), + b"iframe".as_slice(), + b"noembed".as_slice(), + b"noframes".as_slice(), + b"noscript".as_slice(), + ] + .into_iter() + .find(|name| preview_html_tag_name_is(bytes, tag, name)) +} + +fn preview_html_raw_text_close( + bytes: &[u8], + mut cursor: usize, + name: &[u8], +) -> Option<(usize, usize)> { + while cursor < bytes.len() { + let relative_start = bytes[cursor..].iter().position(|byte| *byte == b'<')?; + let close_start = cursor + relative_start; + let name_start = close_start + 2; + let name_end = name_start.checked_add(name.len())?; + if bytes.get(close_start + 1) == Some(&b'/') + && bytes + .get(name_start..name_end) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name)) + && bytes + .get(name_end) + .is_some_and(|byte| preview_html_space(*byte) || matches!(*byte, b'/' | b'>')) + { + let end = preview_html_markup_end(bytes, name_end)?; + return Some((close_start, end)); + } + cursor = close_start + 1; + } + None +} + +fn preview_html_tag_has_bridge_src(bytes: &[u8], tag: PreviewHtmlTag) -> bool { + let mut cursor = tag.name_end; + let limit = tag.end.saturating_sub(1); + let mut first_src = None; + let mut has_type = false; + let mut has_nomodule = false; + while cursor < limit { + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + if cursor >= limit || bytes[cursor] == b'/' { + break; + } + let attribute_start = cursor; + while cursor < limit + && !preview_html_space(bytes[cursor]) + && !matches!(bytes[cursor], b'=' | b'/' | b'>') + { + cursor += 1; + } + if cursor == attribute_start { + cursor += 1; + continue; + } + let attribute_end = cursor; + let is_src = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"src"); + let is_type = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"type"); + let is_nomodule = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"nomodule"); + if is_type { + has_type = true; + } + if is_nomodule { + has_nomodule = true; + } + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + if cursor >= limit || bytes[cursor] != b'=' { + if is_src && first_src.is_none() { + first_src = Some(false); + } + continue; + } + cursor += 1; + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + let (value_start, value_end) = if cursor < limit && matches!(bytes[cursor], b'\'' | b'"') { + let quote = bytes[cursor]; + cursor += 1; + let value_start = cursor; + while cursor < limit && bytes[cursor] != quote { + cursor += 1; + } + let value_end = cursor; + if cursor < limit { + cursor += 1; + } + (value_start, value_end) + } else { + let value_start = cursor; + while cursor < limit && !preview_html_space(bytes[cursor]) && bytes[cursor] != b'>' { + cursor += 1; + } + (value_start, cursor) + }; + if is_src && first_src.is_none() { + first_src = Some(bytes[value_start..value_end] == *PREVIEW_FIT_BRIDGE_PATH.as_bytes()); + } + } + first_src == Some(true) && !has_type && !has_nomodule +} + +fn preview_html_space(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | 0x0c | b'\r' | b' ') +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|candidate| candidate == needle) +} + pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result { let path = url_path.split('?').next().unwrap_or("/"); let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1167a8440..ad043b690 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3440,6 +3440,28 @@ fn local_preview_server_serves_game_index() { assert!(response.contains("200 OK"), "{response}"); assert!(response.contains("还没有生成游戏")); + assert!( + response.contains("/__genarrative/local-preview-fit.js"), + "{response}" + ); + + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .expect("fit bridge request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("fit bridge response"); + assert!(response.contains("200 OK"), "{response}"); + assert!( + response.contains("Content-Type: text/javascript; charset=utf-8"), + "{response}" + ); + assert!( + response.contains("genarrative.local-preview-size.v1"), + "{response}" + ); let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); stream @@ -3458,6 +3480,929 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +fn local_preview_html_response_body(root: &Path, html: &str) -> String { + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/index.html"), html).expect("write preview html"); + let response = build_preview_response(root, "GET", "/"); + let header_end = response + .windows(b"\r\n\r\n".len()) + .position(|window| window == b"\r\n\r\n") + .expect("complete preview response headers") + + b"\r\n\r\n".len(); + String::from_utf8(response[header_end..].to_vec()).expect("UTF-8 preview HTML") +} + +#[test] +fn local_preview_fit_bridge_only_publishes_changed_layout_measurements() { + let root = unique_project_path(); + let response = String::from_utf8(build_preview_response( + &root, + "GET", + "/__genarrative/local-preview-fit.js", + )) + .expect("fit bridge response"); + + assert!( + response.contains("let lastPublishedSize = ''"), + "{response}" + ); + assert!( + response.contains("if (sizeKey === lastPublishedSize) return"), + "{response}" + ); + assert!( + response.contains("new ResizeObserver(schedule)"), + "{response}" + ); + assert!( + response.contains("document.addEventListener('load', schedule, true)"), + "{response}" + ); + assert!( + response.contains("document.fonts?.ready.then(schedule)"), + "{response}" + ); + assert!( + response.contains("const maxContentProbes = 512"), + "{response}" + ); + assert!( + response.contains("if (!document.hidden) schedule()"), + "{response}" + ); + assert!(response.contains("}, 500)"), "{response}"); + assert!(response.contains("truncated || !bodyCoupled"), "{response}"); + assert!( + response.contains("extentDelta * viewportDelta > 0"), + "{response}" + ); + assert!( + response.contains("previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge"), + "{response}" + ); + assert!( + response.contains("previous?.sizeBaseline\n ?? previous?.baseline"), + "{response}" + ); + assert!( + response.contains("previous?.minimumBaseline\n ?? previous?.baseline"), + "{response}" + ); + assert!( + response.contains("element, widthExtent, heightExtent, viewportWidth, viewportHeight"), + "{response}" + ); + assert!( + response.contains("heightExtent, style.height, style.minHeight, viewportHeight"), + "{response}" + ); + assert!( + response.contains("truncated: element !== null"), + "{response}" + ); + assert!( + response.contains("intrinsicBodyWidth, bodyCoupling.width,"), + "{response}" + ); + assert!( + response.contains("const rootRect = root?.getBoundingClientRect()"), + "{response}" + ); + assert!( + response.contains("intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,"), + "{response}" + ); + assert!(response.contains("rootHeightMirrorsBody"), "{response}"); + assert!(response.contains("rootIndependentLayout"), "{response}"); + assert!(response.contains("computedStyleMap?.()"), "{response}"); + assert!( + response.contains("rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0"), + "{response}" + ); + assert!( + response.contains("contentResolutionState.width, viewportWidth, scrollWidth"), + "{response}" + ); + assert!( + response.contains("contentResolutionState.height, viewportHeight, scrollHeight"), + "{response}" + ); + assert!( + response.contains("? Math.max(coupledBaseline || 0, state.resolved || 1)"), + "{response}" + ); + assert!( + response.contains("resetLayoutCouplingSamples(state.axis)"), + "{response}" + ); + assert!( + response.contains("state.responsiveCandidateCount >= 2"), + "{response}" + ); + assert!( + response.contains("} else if (candidateContradicted)"), + "{response}" + ); + assert!( + response.contains("viewportChanged && !hostAppliedPrevious"), + "{response}" + ); + assert!(!response.contains("MutationObserver"), "{response}"); + assert!(!response.contains("childList: true"), "{response}"); + + let resolve_intrinsic_extent = + |body: u32, body_coupled: bool, content: u32, truncated: bool| { + if truncated || !body_coupled { + body.max(content) + } else { + content + } + }; + let resolve_document_extent = |root: u32, + root_coupled: bool, + root_mirrors_body: bool, + body: u32, + body_coupled: bool, + content: u32, + truncated: bool| { + let body_and_content = resolve_intrinsic_extent(body, body_coupled, content, truncated); + resolve_intrinsic_extent( + root, + root_coupled || root_mirrors_body, + body_and_content, + truncated, + ) + }; + let resolve_document_baseline = + |root_baseline: i32, root_mirrors_body: bool, body_baseline: i32, content_baseline: i32| { + (if root_mirrors_body { 0 } else { root_baseline }) + .max(body_baseline) + .max(content_baseline) + }; + let root_mirrors_body = |same_extent: bool, + typed_size: Option<&str>, + typed_minimum: Option<&str>, + independent_latch: &mut bool| { + let typed_available = typed_size.is_some() || typed_minimum.is_some(); + if typed_available { + let size_is_independent = typed_size.is_some_and(|value| value != "auto"); + let minimum_is_independent = typed_minimum + .is_some_and(|value| value != "auto" && value != "0px" && value != "0%"); + *independent_latch = size_is_independent || minimum_is_independent; + } else if !same_extent { + *independent_latch = true; + } + same_extent && !*independent_latch + }; + let resolve_viewport_coupling = |extent: i32, + viewport: i32, + previous_extent: i32, + previous_viewport: i32, + previous_coupled: bool| { + let viewport_delta = viewport - previous_viewport; + if viewport_delta.abs() < 1 { + if (extent - previous_extent).abs() < 1 { + previous_coupled + } else { + false + } + } else { + let extent_delta = extent - previous_extent; + extent_delta.abs() >= 1 && extent_delta * viewport_delta > 0 + } + }; + let fixed_body_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 2_000, 700, false); + assert!(!fixed_body_edge_coupled); + let viewport_min_height_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false); + assert!(viewport_min_height_coupled); + let bottom_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false); + assert!(bottom_edge_coupled); + assert!(resolve_viewport_coupling(2_000, 2_000, 2_000, 2_000, true)); + assert!(!resolve_viewport_coupling(1_000, 2_000, 2_000, 2_000, true)); + assert_eq!(resolve_intrinsic_extent(2_000, true, 1_000, false), 1_000); + assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false), 0); + assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false).max(1), 1); + assert_eq!(resolve_intrinsic_extent(2_000, false, 1_000, false), 2_000); + assert_eq!(resolve_intrinsic_extent(2_000, false, 2_000, false), 2_000); + assert_eq!(resolve_intrinsic_extent(10_000, true, 5_120, true), 10_000); + assert_eq!( + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false), + 2_000, + "a fixed documentElement extent must survive body/descendant aggregation" + ); + assert_eq!( + resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false), + 1_000, + "a documentElement extent mirrored from a responsive body must not become a sticky floor" + ); + assert_eq!( + resolve_document_extent(2_000, false, true, 2_000, false, 1_000, false), + 2_000, + "equal fixed root/body extents must be preserved by the non-coupled body" + ); + let mut typed_root_independent = false; + assert!(!root_mirrors_body( + true, + Some("2000px"), + Some("0px"), + &mut typed_root_independent, + )); + assert_eq!( + resolve_document_extent(2_000, false, false, 2_000, true, 1_000, false), + 2_000, + "a fixed root equal to a coupled body must remain independently authoritative" + ); + let mut typed_root_auto = false; + assert!(root_mirrors_body( + true, + Some("auto"), + Some("0px"), + &mut typed_root_auto, + )); + let mut fallback_root_independent = false; + assert!(root_mirrors_body( + true, + None, + None, + &mut fallback_root_independent, + )); + assert!(!root_mirrors_body( + false, + None, + None, + &mut fallback_root_independent, + )); + assert!(!root_mirrors_body( + true, + None, + None, + &mut fallback_root_independent, + )); + + #[derive(Default)] + struct ResolutionState { + viewport: Option, + scroll: Option, + resolved: Option, + responsive_floor: Option, + responsive: bool, + responsive_candidate_count: u8, + responsive_candidate_floor: Option, + responsive_candidate_direction: i32, + } + let resolve_content_extent = |state: &mut ResolutionState, + viewport: i32, + scroll: i32, + intrinsic: i32, + probed: i32, + truncated: bool, + coupled_baseline: Option| { + let viewport_changed = state + .viewport + .is_some_and(|previous| (viewport - previous).abs() >= 1); + let host_applied_previous = state + .resolved + .is_some_and(|previous| (viewport - previous).abs() <= 1); + let native_reset = viewport_changed && !host_applied_previous; + if native_reset { + state.responsive = false; + state.responsive_floor = None; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + let viewport_delta = state.viewport.map_or(0, |previous| viewport - previous); + let scroll_delta = state.scroll.map_or(0, |previous| scroll - previous); + let responsive_scroll = !native_reset + && host_applied_previous + && viewport_delta.abs() >= 1 + && scroll_delta.abs() >= 1 + && viewport_delta * scroll_delta > 0; + let candidate_contradicted = !state.responsive + && state.responsive_candidate_count > 0 + && ((viewport_changed && host_applied_previous && !responsive_scroll) + || (scroll_delta.abs() >= 1 + && state.responsive_candidate_direction * scroll_delta < 0)); + if responsive_scroll && !state.responsive { + let candidate_floor = if truncated { + Some( + coupled_baseline + .unwrap_or(0) + .max(state.resolved.unwrap_or(1)), + ) + } else { + coupled_baseline.or(state.resolved) + }; + if truncated { + state.responsive = true; + state.responsive_floor = candidate_floor; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } else { + if state.responsive_candidate_count == 0 { + state.responsive_candidate_direction = viewport_delta.signum(); + } + state.responsive_candidate_count += 1; + state.responsive_candidate_floor = + state.responsive_candidate_floor.or(candidate_floor); + if state.responsive_candidate_count >= 2 { + state.responsive = true; + state.responsive_floor = state.responsive_candidate_floor; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + } + } else if candidate_contradicted { + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + let resolved = if state.responsive { + state + .responsive_floor + .unwrap_or(1) + .max(if truncated { probed } else { intrinsic }) + } else if scroll > viewport { + scroll + } else { + intrinsic + }; + if state.responsive && truncated { + state.responsive_floor = Some(state.responsive_floor.unwrap_or(1).max(resolved)); + } + state.viewport = Some(viewport); + state.scroll = Some(scroll); + state.resolved = Some(resolved); + resolved + }; + + let mut proportional_vh = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 700, 840, 840, 0, false, None), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),), + 1_008 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),), + 1_008 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 1_008, 1_210, 0, 0, false, Some(840),), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 840, 840, false, Some(840),), + 840 + ); + + let mut calculated_vh = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 700, 900, 900, 0, false, None), + 900 + ); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 900, 1_100, 0, 0, false, Some(900),), + 1_100 + ); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 1_100, 1_300, 0, 0, false, Some(900),), + 900 + ); + + let mut fixed = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut fixed, 700, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + assert_eq!( + resolve_content_extent(&mut fixed, 2_000, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + + let mut fixed_root = ResolutionState::default(); + let native_fixed_root = + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut fixed_root, + 700, + 2_000, + native_fixed_root, + 1_000, + false, + None, + ), + 2_000 + ); + let fitted_fixed_root = + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut fixed_root, + 2_000, + 2_000, + fitted_fixed_root, + 1_000, + false, + None, + ), + 2_000, + "fitting the viewport must not discard a fixed documentElement extent" + ); + + let mut fixed_grows_during_fit = ResolutionState::default(); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 700, + 2_000, + 2_000, + 2_000, + false, + None, + ), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 2_000, + 2_500, + 0, + 0, + false, + Some(2_000), + ), + 2_500 + ); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 2_500, + 2_500, + 2_500, + 2_500, + false, + None, + ), + 2_500 + ); + + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 840, + 1_500, + 1_500, + 1_500, + false, + Some(840), + ), + 1_500 + ); + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 1_500, + 1_800, + 1_500, + 1_500, + false, + Some(840), + ), + 1_500 + ); + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 1_500, + 1_800, + 500, + 500, + false, + Some(840), + ), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 800, 960, 960, 960, false, None,), + 960 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 960, 1_152, 0, 0, false, None,), + 1_152 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 1_152, 1_382, 0, 0, false, Some(960),), + 960 + ); + + let mut min_height = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut min_height, 700, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 2_000, + 2_000, + 2_000, + 2_000, + false, + Some(700), + ), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 2_000, + 2_000, + 1_000, + 1_000, + false, + Some(700), + ), + 1_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 1_000, + 1_000, + 1_000, + 1_000, + false, + Some(700), + ), + 1_000 + ); + assert_eq!( + resolve_content_extent(&mut min_height, 1_000, 1_000, 700, 500, false, Some(700),), + 700 + ); + assert_eq!( + resolve_content_extent(&mut min_height, 700, 700, 700, 500, false, Some(700),), + 700 + ); + + let mut mirrored_root_min_height = ResolutionState::default(); + let native_mirrored_intrinsic = + resolve_document_extent(2_000, false, true, 2_000, false, 2_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 700, + 2_000, + native_mirrored_intrinsic, + 2_000, + false, + None, + ), + 2_000 + ); + let fitted_mirrored_baseline = resolve_document_baseline(0, true, 700, 0); + let fitted_mirrored_intrinsic = fitted_mirrored_baseline + .max(resolve_document_extent(2_000, false, true, 2_000, true, 2_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 2_000, + 2_000, + fitted_mirrored_intrinsic, + 2_000, + false, + Some(fitted_mirrored_baseline), + ), + 2_000 + ); + let shrunk_mirrored_intrinsic = fitted_mirrored_baseline + .max(resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 2_000, + 2_000, + shrunk_mirrored_intrinsic, + 1_000, + false, + Some(fitted_mirrored_baseline), + ), + 1_000 + ); + let host_applied_mirrored_baseline = resolve_document_baseline(2_000, true, 700, 0); + let host_applied_mirrored_intrinsic = host_applied_mirrored_baseline + .max(resolve_document_extent(1_000, true, true, 1_000, true, 1_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 1_000, + 1_000, + host_applied_mirrored_intrinsic, + 1_000, + false, + Some(host_applied_mirrored_baseline), + ), + 1_000, + "applying the shrink must not restore the mirrored root's old fitted baseline" + ); + + let mut truncated_responsive = ResolutionState::default(); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 700, + 10_000, + 10_000, + 5_120, + false, + None, + ), + 10_000 + ); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 10_000, + 12_000, + 5_120, + 5_120, + true, + Some(700), + ), + 10_000 + ); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 10_000, + 12_000, + 1_000, + 1_000, + true, + Some(700), + ), + 10_000 + ); +} + +#[test] +fn local_preview_bridge_injection_uses_real_html_context_and_supports_omitted_end_tags() { + let bridge = r#""#; + let cases = [ + ( + "missing body and html end tags", + "
game
", + format!("
game
{bridge}"), + ), + ( + "doctype greater-than ends the token even inside a public identifier quote", + " y\">
game
", + format!( + " {bridge} y\">
game
" + ), + ), + ( + "malformed doctype quote before script body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "public identifier remainder contains a script body decoy", + " y\">
game
", + format!( + " y\">
game
{bridge}" + ), + ), + ( + "doctype keyword accepts a missing whitespace parse error", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "script string body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "script double escaped body decoy", + "
x

after

", + format!( + "
x
{bridge}

after

" + ), + ), + ( + "trailing comment body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "comment end bang before script body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "template body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "raw text body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "case insensitive real end tags", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ]; + + for (label, input, expected) in cases { + let root = unique_project_path(); + let actual = local_preview_html_response_body(&root, input); + assert_eq!(actual, expected, "{label}"); + assert_eq!(actual.matches(bridge).count(), 1, "{label}"); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn local_preview_bridge_injection_is_idempotent_and_ignores_inert_path_decoys() { + let bridge = r#""#; + let root = unique_project_path(); + let first = local_preview_html_response_body( + &root, + "
game
", + ); + assert_eq!(first.matches(bridge).count(), 1, "{first}"); + assert!(first.contains(&format!("
game
{bridge}"))); + + let second = local_preview_html_response_body(&root, &first); + assert_eq!(second, first); + assert_eq!(second.matches(bridge).count(), 1, "{second}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_bridge_injection_uses_the_first_duplicate_src_attribute() { + let bridge = r#""#; + let root = unique_project_path(); + let actual = local_preview_html_response_body( + &root, + "
game
", + ); + + assert!(actual.contains(&format!("
game
{bridge}"))); + assert_eq!(actual.matches(bridge).count(), 1, "{actual}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_bridge_injection_ignores_inert_script_src_decoys() { + let bridge = r#""#; + let cases = [ + ( + "non executable type", + "", + ), + ( + "nomodule classic script", + "", + ), + ]; + + for (label, decoy) in cases { + let root = unique_project_path(); + let actual = local_preview_html_response_body( + &root, + &format!("{decoy}
game
"), + ); + assert!( + actual.contains(&format!("
game
{bridge}")), + "{label}" + ); + assert_eq!( + actual + .matches("/__genarrative/local-preview-fit.js") + .count(), + 2, + "{label}" + ); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() { + let bridge = r#""#; + let cases = [ + ( + "comment", + "
game