From 24a5b4e130360b84b062a0bd9dd143984f8d0862 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:56:29 +0900 Subject: [PATCH 01/21] feat(html): enable interactive export runtime --- src/main/html-export-css-sanitize.ts | 50 +++++++++++------------ src/main/html-export-pipeline-service.ts | 5 ++- src/main/html-export-runtime.ts | 19 +++++++++ src/main/html-export-sanitize.ts | 11 ++--- src/main/html-export-shell.ts | 13 ++---- src/renderer/html-export-direct-prompt.ts | 9 ++-- 6 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 src/main/html-export-runtime.ts diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 6bccb71..3a62c68 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -7,13 +7,13 @@ export const CSS_MAX_DECLARATIONS_PER_RULE = 60; export const CSS_MAX_SELECTORS_PER_RULE = 20; export const CSS_MAX_COMPOUND_DEPTH = 8; export const CSS_MAX_NESTING_DEPTH = 4; -export const CSS_MAX_KEYFRAMES = 40; -export const CSS_MAX_FRAMES_PER_KEYFRAMES = 60; -export const CSS_MAX_ANIMATIONS_PER_ELEMENT = 8; -export const CSS_MIN_ANIMATION_DURATION_MS = 50; -export const CSS_MIN_Z_INDEX = 0; -export const CSS_MAX_Z_INDEX = 9_999; -export const CSS_MAX_FONT_SIZE_PX = 400; +export const CSS_MAX_KEYFRAMES = 200; +export const CSS_MAX_FRAMES_PER_KEYFRAMES = 200; +export const CSS_MAX_ANIMATIONS_PER_ELEMENT = 32; +export const CSS_MIN_ANIMATION_DURATION_MS = 0; +export const CSS_MIN_Z_INDEX = -2_147_483_648; +export const CSS_MAX_Z_INDEX = 2_147_483_647; +export const CSS_MAX_FONT_SIZE_PX = 10_000; export const CSS_MAX_VALUE_TOKEN_LENGTH = 512; export const CSS_MAX_DECLARATIONS = 20_000; @@ -49,13 +49,13 @@ const CSS_ALLOWED_PROPERTIES = [ ] as const; const CSS_ALLOWED_FUNCTIONS = [ - 'rgb', 'rgba', 'hsl', 'hsla', 'calc', 'min', 'max', 'clamp', 'linear-gradient', - 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient', 'repeating-radial-gradient', - 'repeating-conic-gradient', 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d', - 'scale', 'scaleX', 'scaleY', 'scaleZ', 'scale3d', 'rotate', 'rotateX', 'rotateY', 'rotateZ', - 'rotate3d', 'skew', 'skewX', 'skewY', 'matrix', 'matrix3d', 'perspective', 'cubic-bezier', - 'steps', 'blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', - 'opacity', 'saturate', 'sepia', + 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'color-mix', + 'calc', 'min', 'max', 'clamp', 'var', 'linear-gradient', 'radial-gradient', 'conic-gradient', + 'repeating-linear-gradient', 'repeating-radial-gradient', 'repeating-conic-gradient', + 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d', 'scale', 'scaleX', 'scaleY', + 'scaleZ', 'scale3d', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'rotate3d', 'skew', 'skewX', + 'skewY', 'matrix', 'matrix3d', 'perspective', 'cubic-bezier', 'steps', 'blur', 'brightness', + 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia', ] as const; const CSS_ALLOWED_AT_RULES = ['media', 'supports', 'keyframes'] as const; @@ -134,10 +134,10 @@ const TYPE_SELECTORS = new Set([ 'p', 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'sub', 'sup', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', 'img', 'picture', 'source', 'svg', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'code', - 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', + 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', 'form', 'input', 'button', ]); -const PSEUDO_CLASSES = new Set(['hover', 'focus', 'focus-visible', 'first-child', 'last-child', 'nth-child', 'not', 'is', 'where']); -const PSEUDO_ELEMENTS = new Set(['before', 'after', 'marker', 'first-line', 'first-letter', 'selection']); +const PSEUDO_CLASSES = new Set(['active', 'any-link', 'checked', 'default', 'defined', 'disabled', 'empty', 'enabled', 'first-child', 'first-of-type', 'focus', 'focus-visible', 'focus-within', 'has', 'hover', 'in-range', 'indeterminate', 'invalid', 'is', 'last-child', 'last-of-type', 'not', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown', 'read-only', 'read-write', 'required', 'root', 'target', 'user-invalid', 'valid', 'visited', 'where']); +const PSEUDO_ELEMENTS = new Set(['after', 'backdrop', 'before', 'first-letter', 'first-line', 'marker', 'placeholder', 'selection']); const SAFE_ATTRIBUTE_SELECTOR_NAMES = new Set([ 'class', 'id', 'title', 'lang', 'dir', 'role', 'data-section-id', 'colspan', 'rowspan', 'scope', 'alt', 'width', 'height', 'datetime', @@ -146,7 +146,7 @@ const MEDIA_FEATURES = new Set(['width', 'min-width', 'max-width', 'height', 'or const FONT_SIZE_KEYWORDS = new Set(['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large']); const RESERVED_NAME = /^(?:data-he-|he-s|he-(?:doc|slide|scaler|runtime|manifest|shell|csp)|(?:data-)?(?:shell|runtime|manifest|csp))/i; const NETWORK_FUNCTION = new Set(['url', 'image', 'image-set', '-webkit-image-set', 'cross-fade', 'element', 'expression']); -const VALUE_INDIRECTION_FUNCTION = new Set(['attr', 'env', 'paint', 'var']); +const VALUE_INDIRECTION_FUNCTION = new Set(['attr', 'env', 'paint']); const ANIMATION_KEYWORDS = new Set([ 'none', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end', 'infinite', 'normal', 'reverse', 'alternate', 'alternate-reverse', 'forwards', 'backwards', @@ -462,7 +462,7 @@ function validateSelectorAtom(node: any): Failure | null { if ((name === 'class' && hasReservedName(selectedValue)) || (name === 'id' && (hasReservedName(selectedValue) || canonicalizeIdent(selectedValue).startsWith('he-')))) { return fail(CSS_VIOLATION_CODES.reservedSelector, `reserved attribute selector ${name}`); } - if (!SAFE_ATTRIBUTE_SELECTOR_NAMES.has(name) && !name.startsWith('aria-')) { + if (!SAFE_ATTRIBUTE_SELECTOR_NAMES.has(name) && !name.startsWith('aria-') && !name.startsWith('data-')) { return fail(CSS_VIOLATION_CODES.disallowedSelector, `attribute selector ${name}`); } } else if (type === 'PseudoClassSelector') { @@ -537,10 +537,10 @@ function rawFunctionFailure(raw: string): Failure | null { const match = /([a-z-]+)\s*\(/i.exec(raw); if (!match) return null; const name = match[1].toLowerCase(); - if (name === 'var') return fail(CSS_VIOLATION_CODES.customProperty, 'var()'); if (NETWORK_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.networkFunction, `${name}()`); if (VALUE_INDIRECTION_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.valueIndirection, `${name}()`); - return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); + if (!FUNCTION_SET.has(name)) return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); + return null; } function validateValue(value: any): Failure | null { @@ -552,7 +552,6 @@ function validateValue(value: any): Failure | null { if (node?.type === 'Url') return fail(CSS_VIOLATION_CODES.networkFunction, 'url()'); if (node?.type === 'Function') { const name = String(node.name).toLowerCase(); - if (name === 'var') return fail(CSS_VIOLATION_CODES.customProperty, 'var()'); if (NETWORK_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.networkFunction, `${name}()`); if (VALUE_INDIRECTION_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.valueIndirection, `${name}()`); if (!FUNCTION_SET.has(name)) return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); @@ -682,7 +681,7 @@ function validateContent(value: any): Failure | null { function validateNumericCaps(property: string, value: any, context: CssSanitizeContext): Failure | null { if (property === 'position') { const position = generated(value).toLowerCase(); - if (position === 'fixed' || position === 'sticky') return fail(CSS_VIOLATION_CODES.unsafePosition, `position:${position} is not allowed`); + if (!['static', 'relative', 'absolute', 'fixed', 'sticky'].includes(position)) return fail(CSS_VIOLATION_CODES.unsafePosition, `position:${position} is not allowed`); } if (property === 'z-index') { const tokens = children(value); @@ -716,13 +715,12 @@ function sanitizeDeclarations(block: any, context: CssSanitizeContext, counts: C context.seenDeclarations++; if (context.seenDeclarations > CSS_MAX_DECLARATIONS) { strip(context, fail(CSS_VIOLATION_CODES.tooManyDeclarations, `more than ${CSS_MAX_DECLARATIONS} declarations`)); break; } const property = String(declaration.property).toLowerCase(); - let failure: Failure | null = declaration.important ? fail(CSS_VIOLATION_CODES.important, `!important on ${property}`) : null; - if (!failure && property.startsWith('--')) failure = fail(CSS_VIOLATION_CODES.customProperty, `custom property ${property}`); + let failure: Failure | null = null; if (!failure) failure = validateValue(declaration.value); if (!failure && PROPERTY_SET.has(property)) failure = validateNumericCaps(property, declaration.value, context); if (failure) { strip(context, failure); continue; } const value = generated(declaration.value); - if (!PROPERTY_SET.has(property) || lexer.matchProperty(property, value).error) continue; + if ((!PROPERTY_SET.has(property) && !property.startsWith('--')) || (!property.startsWith('--') && lexer.matchProperty(property, value).error)) continue; output.push(`${property}:${value}`); counts.declarationCount++; } diff --git a/src/main/html-export-pipeline-service.ts b/src/main/html-export-pipeline-service.ts index 4f576b8..f858e45 100644 --- a/src/main/html-export-pipeline-service.ts +++ b/src/main/html-export-pipeline-service.ts @@ -20,6 +20,7 @@ import { HtmlExportAttemptRegistry } from './html-export-attempt-registry'; import { HtmlExportParseHost, type HtmlExportParseValue } from './html-export-parse-host'; import { findHtmlExportDocumentMarkers } from './html-export-document-markers'; import { HTML_SANITIZER_LIMITS, sanitizeHtmlExport } from './html-export-sanitize'; +import { injectHtmlExportRuntime, type HtmlExportRuntimeMode } from './html-export-runtime'; export const HTML_EXPORT_RAW_MODEL_OUTPUT_MAX_BYTES = HTML_EXPORT_RAW_ARTIFACT_MAX_BYTES; export const HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES = HTML_EXPORT_STAGE_ARTIFACT_MAX_BYTES; @@ -459,6 +460,7 @@ export class HtmlExportPipelineService { webContentsId: number, attemptId: HtmlExportAttemptId, resolvedArtifactId: ResolvedArtifactId, + mode: HtmlExportRuntimeMode = 'scroll', ): HtmlExportPipelineResult<{ artifact: HtmlExportArtifactRef<'finalized'> }> { const resolved = this.registry.read(webContentsId, attemptId, resolvedArtifactId, 'resolved'); if (!resolved.ok) return resolved; @@ -469,8 +471,9 @@ export class HtmlExportPipelineService { return oversize(`Resolved payload exceeds ${HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES} bytes`); } + const finalizedHtml = injectHtmlExportRuntime(new TextDecoder('utf-8').decode(resolved.value.bytes), mode); const verified = this.verifyCandidate( - Buffer.from(resolved.value.bytes), + Buffer.from(finalizedHtml, 'utf8'), HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES, ); if (!verified.ok) return verified; diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts new file mode 100644 index 0000000..1029e31 --- /dev/null +++ b/src/main/html-export-runtime.ts @@ -0,0 +1,19 @@ +export type HtmlExportRuntimeMode = 'scroll' | 'slide'; + +export const HTML_EXPORT_INTERACTIVE_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'"; +export const HTML_EXPORT_INTERACTIVE_CSP_META = ``; + +function runtimeSource(mode: HtmlExportRuntimeMode): string { + const slide = mode === 'slide' ? 'true' : 'false'; + return `(function(){if(document.getElementById('nai-runtime'))return;var root=document.documentElement,button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime';button.type='button';button.className='nai-theme-toggle';button.style.cssText='position:fixed;top:12px;right:12px;z-index:2147483647;border:0;border-radius:999px;padding:8px 10px;cursor:pointer;background:#111;color:#fff';function setTheme(theme){root.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';button.setAttribute('aria-label',theme==='dark'?'Switch to light theme':'Switch to dark theme')}var saved;try{saved=localStorage.getItem('nai-theme')}catch(_e){}setTheme(saved==='light'||saved==='dark'?saved:(matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'));button.addEventListener('click',function(){setTheme(root.dataset.theme==='dark'?'light':'dark')});document.body.appendChild(button);var authored=Array.prototype.some.call(document.querySelectorAll('style'),function(s){return /\\[data-theme[^}]*--/.test(s.textContent||'')});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-theme="dark"] img,[data-theme="dark"] video{filter:invert(1) hue-rotate(180deg)}';document.head.appendChild(style)}if(${slide}){var slides=Array.prototype.slice.call(document.querySelectorAll('section.slide'));if(!slides.length){slides=Array.prototype.slice.call(document.body.children).filter(function(node){return node.tagName==='SECTION'})}if(!slides.length)return;var index=0,controls=document.createElement('div'),previous=document.createElement('button'),next=document.createElement('button'),indicator=document.createElement('span');controls.className='nai-slide-nav';controls.style.cssText='position:fixed;bottom:12px;right:12px;z-index:2147483647;display:flex;gap:8px;align-items:center;background:#111;color:#fff;padding:8px;border-radius:999px';previous.type=next.type='button';previous.textContent='‹';next.textContent='›';function show(n){index=(n+slides.length)%slides.length;slides.forEach(function(s,i){s.style.display=i===index?'':'none';s.style.minHeight='100vh'});indicator.textContent=(index+1)+'/'+slides.length;slides[index].scrollIntoView({block:'start'})}previous.addEventListener('click',function(){show(index-1)});next.addEventListener('click',function(){show(index+1)});controls.append(previous,indicator,next);document.body.appendChild(controls);document.addEventListener('keydown',function(event){var target=event.target;if(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement||target&&target.isContentEditable)return;if(['ArrowRight','PageDown',' '].includes(event.key)){event.preventDefault();show(index+1)}else if(['ArrowLeft','PageUp'].includes(event.key)){event.preventDefault();show(index-1)}});show(0)}})();`; +} + +/** Adds the app-owned runtime after sanitization. Repeated finalization is idempotent. */ +export function injectHtmlExportRuntime(html: string, mode: HtmlExportRuntimeMode = 'scroll'): string { + let output = html.replace(/]*>\s*/gi, ''); + const head = `${HTML_EXPORT_INTERACTIVE_CSP_META}`; + output = /<\/head\s*>/i.test(output) ? output.replace(/<\/head\s*>/i, `${head}`) : `${head}${output}`; + if (/id=["']nai-runtime["']/i.test(output)) return output; + const script = ``; + return /<\/body\s*>/i.test(output) ? output.replace(/<\/body\s*>/i, `${script}
are transferred so rewritten // selectors like `[data-he-content].dark` still match (Codex P2). - `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n\n` + + `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n` + '\n'; - - return { html, manifest }; + return { html: injectHtmlExportRuntime(html), manifest }; } diff --git a/src/renderer/html-export-direct-prompt.ts b/src/renderer/html-export-direct-prompt.ts index fc1a0b7..28b7fe2 100644 --- a/src/renderer/html-export-direct-prompt.ts +++ b/src/renderer/html-export-direct-prompt.ts @@ -90,6 +90,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { `- ${modeLine(config.mode)}`, `- ${densityLine(config.density)}`, ]; + if (config.mode === 'slide') lines.push('- slide markup contract: every slide MUST be exactly , and each slide MUST occupy one viewport.'); if (config.customPurpose) { lines.push(`- custom purpose brief (weight heavily): ${config.customPurpose}`); @@ -111,7 +112,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { if (typeof config.interactive === 'boolean') { lines.push( config.interactive - ? '- interactivity: allow tasteful CSS-only interactions (no JavaScript)' + ? '- interactivity: inline JavaScript runs in the final document. Author frontier-quality, self-contained interactions appropriate to the content (tabs, accordions, hover states, animated reveals, inline-SVG chart interactions, and counters). Network APIs are unavailable under CSP; keep everything inline.' : '- interactivity: static document only (no interactive affordances)', ); } @@ -164,9 +165,9 @@ const HTML_EXPORT_DIRECT_DESIGN_KNOWLEDGE = [ '1. Classify the screen by reader task (narrative/marketing, report/dashboard, article/reference, instruction, or command) and turn the source into jobs — introduce, explain, substantiate, compare, decide, orient, retain — sequenced for that job, not for fashion.', '2. Preserve the source reading order and distinguish titles, prose, lists, tables, quotations, code, and data with real semantic HTML; keep evidence adjacent to its claim.', '3. Name the layout problem (flow, repetition, comparison, or primary/supporting context) and choose HTML structure + CSS that solves it: restrained flow for explanation, parallel items for repeated facts, tables for comparison.', - '4. Author complete, self-contained HTML with inline CSS — no scripts, no external fonts/assets. IMAGES: anKept
Text'); }); - it('unwraps content-bearing active containers while recording their removal', () => { + it('preserves interactive form containers', () => { const result = sanitize(''); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('Kept
'); - expect(result.bodyHtml).not.toContain(''); }); it('unwraps template content stored outside its childNodes array', () => { const result = sanitize('Template text
x
')).toBe('html_event_handler'); + it('preserves event handlers while rejecting app shell/runtime namespace preseed', () => { + expect(dispositionCode('x
')).toBe(''); expect(dispositionCode('x
')).toBe('html_reserved_namespace'); expect(dispositionCode('x
')).toBe('html_reserved_namespace'); expect(dispositionCode('x
')).toBe('html_reserved_namespace'); }); - it('removes stripped event and reserved attributes from exported HTML', () => { + it('keeps interactive event attributes while stripping reserved attributes', () => { const result = sanitize('Kept
'); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('Kept'); - expect(result.bodyHtml).not.toContain('onclick'); + expect(result.bodyHtml).toContain('onclick="x"'); expect(result.bodyHtml).not.toContain('data-he-'); expect(result.bodyHtml).not.toContain('he-shell'); }); @@ -294,41 +293,33 @@ describe('sanitizeHtmlExport', () => { expect(stylesheet).toMatchObject({ ok: true, stripped: ['css_rejected.css_network_function_not_allowed'] }); const inline = sanitize('safe
'); - expect(inline).toMatchObject({ ok: true, stripped: ['css_rejected.css_important_not_allowed'] }); - if (inline.ok) { - expect(inline.bodyHtml).not.toContain('style='); - expect(inline.stripped.join()).not.toContain('color'); - } + expect(inline).toMatchObject({ ok: true, stripped: [] }); + if (inline.ok) expect(inline.contentCss).toContain('color:red!important'); }); it('strips oversized malformed CSS before stylesheet registration can parse it', () => { const result = sanitize(``); expect(result.ok).toBe(true); if (result.ok) expect(result.stripped).toContain('css_rejected.css_too_large'); }); - it('keeps the document and ordinary rules while stripping :is() sticky declarations', () => { + it('keeps interactive sticky declarations', () => { const result = sanitize('Kept
Also kept
'); - expect(result).toMatchObject({ ok: true, stripped: ['css_rejected.css_unsafe_position'] }); + expect(result).toMatchObject({ ok: true, stripped: [] }); if (!result.ok) return; expect(result.bodyHtml).toContain('Kept'); - expect(result.contentCss).toContain(':is(.note){color:red}'); + expect(result.contentCss).toContain(':is(.note){position:sticky;color:red}'); expect(result.contentCss).toContain('.plain{color:blue}'); - expect(result.contentCss).not.toContain('sticky'); }); - it('gives active and style-node attribute failures precedence over malformed nested CSS', () => { + it('keeps reserved namespace checks ahead of malformed CSS', () => { for (const [html, code] of [ - ['', 'html_active_tag'], - ['', 'html_active_tag'], - ['', 'html_event_handler'], - ['x
', 'html_event_handler'], ['', 'html_reserved_namespace'], ]) { expect(failureCode(html)).toBe(code); } }); it.each([ - ['active ancestor', '', 'html_active_tag'], - ['event ancestor', '', 'html_event_handler'], + ['active ancestor', '', 'html_svg_rejected'], + ['event ancestor', '', 'html_svg_rejected'], ['reserved ancestor', '', 'html_reserved_namespace'], ['structural ancestor', '', 'html_attribute'], ])('gives outer HTML boundaries precedence over malformed SVG: %s', (_name, html, code) => { @@ -739,7 +730,6 @@ describe('sanitizeHtmlExport — fail-closed structural gate (issue #27)', () => } const custom = sanitize('x
'); expect(custom.ok).toBe(true); - if (custom.ok) expect(custom.stripped).toContain('css_rejected.css_custom_property_not_allowed'); }); it('emits no content-root style rule when html/body have no style attribute', () => { diff --git a/src/__tests__/html-export-shell.test.ts b/src/__tests__/html-export-shell.test.ts index 89c3f28..067c8bc 100644 --- a/src/__tests__/html-export-shell.test.ts +++ b/src/__tests__/html-export-shell.test.ts @@ -39,11 +39,11 @@ function scriptBlocks(html: string): Array<{ type: string | null; id: string | n } describe('bundleSanitizedHtml — canonical shell contract', () => { - it('emits exactly one CSP meta whose script-src pins the shared runtime SHA', () => { + it('emits exactly one interactive CSP meta', () => { const { html } = bundleSanitizedHtml(payload()); const cspMetas = html.match(/]*>/g) ?? []; expect(cspMetas).toHaveLength(1); - expect(cspMetas[0]).toContain(`script-src 'sha256-${HTML_EXPORT_RUNTIME_JS_SHA256}'`); + expect(cspMetas[0]).toContain("script-src 'unsafe-inline'"); }); it('emits exactly two