diff --git a/docs/generic-behavior-discovery.md b/docs/generic-behavior-discovery.md new file mode 100644 index 00000000..2e47ca77 --- /dev/null +++ b/docs/generic-behavior-discovery.md @@ -0,0 +1,63 @@ +# Generic source behavior discovery + +Plain URL capture learns diagnosed dynamic behavior from the source itself, +with no site recipe. For each page `source-interactivity.json` marks +`unreproduced`, capture: + +1. **Observes** the live source twice under controlled `Date` values and + timezones. Instrumentation installed before source scripts records + timestamped text of leaf and mixed-content elements, visibility changes with + their mechanism (`visibility` or `display`), click/pointer listener + registrations, and 2D canvas drawing calls. Startup ends when text has been + quiet (bounded); each discovered click is probed from a settled page. +2. **Learns** an editable program in the Blocks Engine motion vocabulary + (`learned-motion.ts`): + - sequential text reveals: per-character cadence, delay after the previous + step, and click replay trigger and delay; + - a static pending message with the common cycling-dot suffix; + - elements hidden until a reveal starts (`revealSelectors`); + - a live clock: which elements carry hour, minute, AM/PM, GMT offset and + locale date (proved by the two controlled times), plus its startup and + replay frames and date reveal. + Values are measured, never embedded editorial copy: the runtime replays the + current DOM text, so an edited page replays its edited value. +3. **Reports** everything the vocabulary cannot express in `unsupported` + (unrecognized text or visibility changes, unmatched time-derived text, + concurrent reveals, and canvas drawing). Nothing is substituted. +4. **Reuses source canvas code** (`canvas-sandbox.ts`). A canvas drawing + algorithm is not re-derived: the page's own captured, hash-verified scripts + that draw on a canvas run behind a DOM membrane. Canvas elements, drawing + contexts and event listeners are live; reads return real values; every write + to non-canvas content is discarded, so the source can never overwrite the + editable page. If the sandboxed copy does not reproduce the source pointer + response, promotion falls back to learned motion with canvas as a residual. +5. **Promotes** (`learned-motion-promotion.ts`) only after a staged portable + copy reproduces the learned behavior against the live source at + 390/768/1440px with a contract derived from the evidence. The markers and + the portable interpreter (`motion-runtime.ts`) are then written to + `website/`, with a `portable-motion.json` receipt (`origin: "learned"`) + carrying the derived contract and the unsupported residuals. + +Plain `compare` re-verifies the receipt on every run and fails visibly for each +residual: `source behavior not translated (): `. + +## WordPress + +The page carries inert `data-blocks-engine-motion-steps` and +`data-blocks-engine-live-clock` markers. Blocks Engine lowers them to editable +motion-sequence and live-clock blocks with their own view scripts. The portable +interpreter is tagged `data-blocks-engine-marker-runtime`, so the importer +replaces it with those view scripts rather than loading both. + +## Current limits + +- Canvas: source canvas code runs only if it still works when its content + writes are discarded; code whose drawing depends on content it would have + created itself stays an explicit residual. In WordPress the sandbox is a + preserved runtime island (`runtime_js`); page content remains native blocks. +- Measured timings carry timer jitter from the observing machine; the fidelity + gate compares observable phases and settled state, not exact milliseconds. +- The vocabulary expresses sequential reveals, pending dots, reveal visibility + and one live clock per page; other patterns are reported, not approximated. + +Tracker: https://github.com/Automattic/data-liberation-agent/issues/425 diff --git a/src/lib/behavior-discovery.ts b/src/lib/behavior-discovery.ts new file mode 100644 index 00000000..b21b91d6 --- /dev/null +++ b/src/lib/behavior-discovery.ts @@ -0,0 +1,70 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { sourceContextOptions } from './browser-kit/browser-kit.js'; +import { captureSourceBehavior } from './screenshot/behavior-capture.js'; +import { modelSourceBehavior, timeDependentTargets } from './behavior-model.js'; +import { learnMotion, type LearnedMotion } from './learned-motion.js'; +import { promoteLearnedMotion, type LearnedPromotion } from './learned-motion-promotion.js'; + +const FIRST_TIME = { at: '2031-02-03T04:17:00Z', timezone: 'UTC' }; +const SECOND_TIME = { at: '2032-08-14T19:43:00Z', timezone: 'Europe/Berlin' }; + +/** + * Learn diagnosed behavior from the source itself (bounded site-wide browser + * budget), then promote it only if the staged portable site reproduces it. + */ +export async function discoverCapturedBehavior( directory: string ): Promise< void > { + const path = join( directory, 'source-interactivity.json' ); + if ( ! existsSync( path ) ) return; + const source = JSON.parse( readFileSync( path, 'utf8' ) ) as { pages?: Array< { url: string; status: string } > }; + const pages = ( source.pages ?? [] ).filter( ( page ) => page.status === 'unreproduced' ); + if ( ! pages.length ) return; + const { chromium } = await import( 'playwright' ); + const browser = await chromium.launch(); + const observations: Array< Record< string, unknown > > = []; + const learned: Array< { url: string; learned: LearnedMotion } > = []; + const failures: Array< { url: string; error: string } > = []; + let promotion: LearnedPromotion | null = null; + const attempts: Array< { sandboxCanvas: boolean; promoted: boolean; failures: string[] } > = []; + try { + for ( const { url } of pages.slice( 0, 4 ) ) { + const open = async ( timezoneId: string ) => browser.newPage( { ...await sourceContextOptions( browser, url ), timezoneId, viewport: { width: 1440, height: 900 } } ); + const page = await open( FIRST_TIME.timezone ); + const counterfactualPage = await open( SECOND_TIME.timezone ); + try { + const evidence = await captureSourceBehavior( page, url, { fixedTime: FIRST_TIME.at } ); + // A second controlled date and timezone separates time-derived text from editorial text. + const counterfactual = await captureSourceBehavior( counterfactualPage, url, { fixedTime: SECOND_TIME.at, maxClicks: 0 } ); + const motion = learnMotion( evidence, counterfactual ); + learned.push( { url, learned: motion } ); + observations.push( { + url, evidence, counterfactual, learned: motion, + model: { ...modelSourceBehavior( evidence ), dynamicTime: timeDependentTargets( evidence, counterfactual ), timeProbe: { first: FIRST_TIME, second: SECOND_TIME } }, + } ); + } catch ( error ) { + failures.push( { url, error: error instanceof Error ? error.message : String( error ) } ); + } finally { + await Promise.all( [ page.close(), counterfactualPage.close() ] ); + } + } + if ( learned.length ) { + // Prefer running the source's own canvas code behind the content membrane; + // if that does not verify, promote the learned motion alone with canvas as a residual. + const canvas = learned.some( ( row ) => row.learned.unsupported.some( ( item ) => item.kind === 'canvas' ) ); + if ( canvas ) { + const sandboxed = await promoteLearnedMotion( directory, learned, browser, { sandboxCanvas: true } ); + attempts.push( { sandboxCanvas: true, promoted: sandboxed.promoted, failures: sandboxed.failures } ); + if ( sandboxed.promoted ) promotion = sandboxed; + } + promotion ??= await promoteLearnedMotion( directory, learned, browser ); + } + } finally { await browser.close(); } + const receipt = existsSync( join( directory, 'portable-motion.json' ) ) ? JSON.parse( readFileSync( join( directory, 'portable-motion.json' ), 'utf8' ) ) as { unsupported?: Record< string, unknown[] > } : null; + const residual = Object.values( receipt?.unsupported ?? {} ).some( ( rows ) => rows.length ); + writeFileSync( join( directory, 'source-behavior.json' ), JSON.stringify( { + schema: 'data-liberation/source-behavior-report/v1', observed: observations.length, + total: pages.length, omitted: Math.max( 0, pages.length - 4 ), observations, failures, + promotion: promotion && { promoted: promotion.promoted, failures: promotion.failures, evidence: promotion.evidence, attempts }, + status: promotion?.promoted ? ( residual ? 'partially_translated' : 'translated' ) : 'observed_untranslated', + }, null, 2 ) ); +} diff --git a/src/lib/behavior-model.test.ts b/src/lib/behavior-model.test.ts new file mode 100644 index 00000000..9f6caef9 --- /dev/null +++ b/src/lib/behavior-model.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { learnTextReveals } from './behavior-model.js'; +import type { BehaviorTrace } from './screenshot/behavior-capture.js'; + +const trace = ( text: BehaviorTrace[ 'text' ] ): BehaviorTrace => ( { text, visibility: {}, canvas: {}, events: [], truncated: false } ); +describe( 'observed text reveal semantics', () => { + it( 'learns different target IDs, text and timer intervals from evidence', () => { + for ( const [ name, value, interval ] of [ [ '#first', 'HELLO', 30 ], [ '#renamed', 'Changed copy', 72 ] ] as const ) { + const history = [ { at: 0, text: '' }, ...Array.from( value ).map( ( _, index ) => ( { at: 400 + index * interval, text: value.slice( 0, index + 1 ) } ) ) ]; + expect( learnTextReveals( trace( { [ name ]: history } ) ) ).toEqual( [ { selector: name, startMs: 400, intervalMs: interval, characters: value.length, confidence: 'observed_prefix_progression' } ] ); + } + } ); + it( 'does not reinterpret a clock, random counter or final snapshot as a typewriter', () => { + expect( learnTextReveals( trace( { '#counter': [ { at: 0, text: '10' }, { at: 50, text: '11' }, { at: 100, text: '12' }, { at: 150, text: '13' } ], '#static': [ { at: 0, text: 'Visible copy' } ] } ) ) ).toEqual( [] ); + } ); + it( 'keeps cyclic loading dots distinct from one-shot editorial reveal', () => { + const frames = [ '', '.', '..', '...', '', '.', '..', '...' ].map( ( text, index ) => ( { at: index * 100, text } ) ); + expect( learnTextReveals( trace( { '#cycle': frames } ) ) ).toEqual( [] ); + } ); + it( 'learns the rate when coalesced mutation sampling skips characters', () => { + const frames = [ { at: 0, text: '' }, { at: 100, text: 'A' }, { at: 160, text: 'ABC' }, { at: 190, text: 'ABCD' }, { at: 250, text: 'ABCDEF' } ]; + expect( learnTextReveals( trace( { '#coalesced': frames } ) )[ 0 ] ).toMatchObject( { selector: '#coalesced', startMs: 100, intervalMs: 30, characters: 6 } ); + } ); + it( 'rejects an instantaneous whole-string swap as a reveal', () => { + const frames = [ { at: 0, text: '' }, { at: 100, text: 'A' }, { at: 101, text: 'AB' }, { at: 102, text: 'ABCDEFGHIJKL' } ]; + expect( learnTextReveals( trace( { '#swap': frames } ) ) ).toEqual( [] ); + } ); + it( 'recognizes a click replay resetting existing saved text before revealing it', () => { + const frames = [ 'WORD', '', 'W', 'WO', 'WOR', 'WORD' ].map( ( text, index ) => ( { at: index * 30, text } ) ); + expect( learnTextReveals( trace( { '#saved': frames } ) )[ 0 ] ).toMatchObject( { selector: '#saved', intervalMs: 30, characters: 4 } ); + } ); +} ); diff --git a/src/lib/behavior-model.ts b/src/lib/behavior-model.ts new file mode 100644 index 00000000..4ef006b9 --- /dev/null +++ b/src/lib/behavior-model.ts @@ -0,0 +1,79 @@ +import type { BehaviorTrace, SourceBehavior } from './screenshot/behavior-capture.js'; + +export interface LearnedTextReveal { + selector: string; + /** Relative to phase start; source text is evidence, never runtime replay payload. */ + startMs: number; + intervalMs: number; + characters: number; + confidence: 'observed_prefix_progression'; +} + +/** Only classify observed one-character prefix progressions. Other DOM changes remain unknown. */ +export function learnTextReveals( trace: BehaviorTrace ): LearnedTextReveal[] { + const result: LearnedTextReveal[] = []; + for ( const [ selector, states ] of Object.entries( trace.text ) ) { + if ( states.length < 4 ) continue; + const final = states.at( -1 )!.text; + if ( final.length < 3 || final.length > 512 ) continue; + // Cycling dots and other repeated prefixes are not a one-shot text reveal. + const timeline = states[ 0 ].text === final && states[ 1 ]?.text !== final ? states.slice( 1 ) : states; + const prefixes = timeline.filter( ( state ) => final.startsWith( state.text ) ); + if ( prefixes.slice( 1 ).some( ( state, index ) => state.text.length < prefixes[ index ].text.length ) ) continue; + const frames = timeline.filter( ( state ) => state.text.length > 0 && final.startsWith( state.text ) ); + // Browsers coalesce mutation callbacks under load, so observed frames may + // skip characters. Accept strictly growing prefixes and measure the + // per-character rate over the whole run rather than per frame. + let start = frames.length - 1; + while ( start > 0 && frames[ start ].text.length > frames[ start - 1 ].text.length && frames[ start ].at > frames[ start - 1 ].at ) start--; + const sequence = frames.slice( start ); + const first = Array.from( sequence[ 0 ]?.text ?? '' ).length; + const last = Array.from( final ).length; + if ( sequence.length < 3 || first > 2 || sequence.at( -1 )!.text !== final ) continue; + const average = ( sequence.at( -1 )!.at - sequence[ 0 ].at ) / Math.max( 1, last - first ); + // Timer jitter only ever delays a character. The lower quartile of the + // single-character steps (excluding catch-up bursts) estimates the + // requested rate better than the load-inflated average. + const singles = sequence.slice( 1 ) + .filter( ( frame, index ) => Array.from( frame.text ).length - Array.from( sequence[ index ].text ).length === 1 ) + .map( ( frame ) => frame.at - sequence[ sequence.indexOf( frame ) - 1 ].at ) + .filter( ( value ) => value >= average * .5 ) + .sort( ( a, b ) => a - b ); + const intervalMs = Math.round( singles.length >= 4 ? singles[ Math.floor( singles.length / 4 ) ] : average ); + if ( intervalMs < 5 || intervalMs > 1000 ) continue; + // Reject bursts: a multi-character jump must have taken plausible time for + // that rate. Single-character steps are always valid; timer jitter can + // deliver two consecutive ticks only milliseconds apart. + if ( sequence.slice( 1 ).some( ( frame, index ) => { + const added = Array.from( frame.text ).length - Array.from( sequence[ index ].text ).length; + return added > 1 && frame.at - sequence[ index ].at < ( added - 1 ) * intervalMs * .25; + } ) ) continue; + result.push( { selector, startMs: sequence[ 0 ].at, intervalMs, characters: Array.from( final ).length, confidence: 'observed_prefix_progression' } ); + } + return result.sort( ( a, b ) => a.startMs - b.startMs ); +} + +/** Explicit evidence/model separation: a canvas trace is not a translated canvas algorithm. */ +export function modelSourceBehavior( observed: SourceBehavior ) { + return { + schema: 'data-liberation/behavior-model/v1', + url: observed.url, + startup: learnTextReveals( observed.startup ), + replays: observed.replays.map( ( replay ) => ( { trigger: replay.selector, textReveals: learnTextReveals( replay.trace ) } ) ), + unsupported: Object.keys( observed.pointer.trace.canvas ).map( ( selector ) => ( { + selector, capability: 'canvas-algorithm', reason: 'Drawing API observations do not reconstruct pointer-dependent source computation', + methods: Object.keys( observed.pointer.trace.canvas[ selector ].methods ), + } ) ), + truncated: observed.startup.truncated || observed.pointer.trace.truncated || observed.replays.some( ( replay ) => replay.trace.truncated ), + status: 'observed_untranslated', + }; +} + +/** A counterfactual Date probe distinguishes dynamic time text from editorial prefixes. */ +export function timeDependentTargets( first: SourceBehavior, second: SourceBehavior ): string[] { + return Object.keys( first.startup.text ).filter( ( selector ) => { + const initial = first.startup.settledText ? first.startup.settledText[ selector ] : first.startup.text[ selector ].at( -1 )?.text; + const counterfactual = second.startup.settledText ? second.startup.settledText[ selector ] : second.startup.text[ selector ]?.at( -1 )?.text; + return initial !== undefined && counterfactual !== undefined && initial !== counterfactual; + } ); +} diff --git a/src/lib/canvas-sandbox.test.ts b/src/lib/canvas-sandbox.test.ts new file mode 100644 index 00000000..799c864a --- /dev/null +++ b/src/lib/canvas-sandbox.test.ts @@ -0,0 +1,74 @@ +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildCanvasSandbox, drawsOnCanvas } from './canvas-sandbox.js'; + +// Neutral source code: overwrites copy, hides and restyles elements, rebuilds +// markup, and draws on pointer movement and clicks. +const SOURCE = ` +const copy = document.getElementById('copy'); +copy.textContent = 'SOURCE OVERWRITE'; +copy.style.display = 'none'; +copy.classList.add('hidden-by-source'); +copy.setAttribute('data-source', 'yes'); +document.querySelector('#panel').innerHTML = 'x'; +document.getElementById('injected').textContent = 'still inert'; +document.body.appendChild(document.createElement('section')); +const surface = document.getElementById('surface'); +surface.width = 300; surface.height = 120; +const context = surface.getContext('2d'); +let strokes = 0; +window.addEventListener('mousemove', (event) => { + context.strokeStyle = '#123456'; context.beginPath(); + context.moveTo(event.clientX % 300, 10); context.lineTo((event.clientX + 40) % 300, 100); context.stroke(); strokes++; +}); +document.querySelectorAll('.tile').forEach((tile) => tile.addEventListener('click', (event) => { + event.target.textContent = 'clicked'; + const box = event.currentTarget.getBoundingClientRect(); + context.fillStyle = '#654321'; context.fillRect(0, 0, Math.max(1, Math.round(box.width) % 50), 20); +})); +document.getElementById('panel').onclick = function () { this.textContent = 'handler wrote'; context.fillRect(200, 0, 10, 10); }; +const extra = document.createElement('canvas'); extra.id = 'extra'; document.body.appendChild(extra); +window.__strokes = () => strokes; +`; + +describe( 'source canvas sandbox', () => { + let browser: Browser; + beforeAll( async () => { browser = await chromium.launch(); } ); + afterAll( async () => { await browser.close(); } ); + + it( 'selects only drawing code', () => { + expect( drawsOnCanvas( SOURCE ) ).toBe( true ); + expect( drawsOnCanvas( 'navigator.sendBeacon("/rum")' ) ).toBe( false ); + } ); + + it( 'runs source drawing and listeners while every content write is discarded', async () => { + const page = await browser.newPage( { viewport: { width: 800, height: 600 } } ); + const errors: string[] = []; + page.on( 'pageerror', ( error ) => errors.push( error.message ) ); + page.on( 'console', ( message ) => { if ( message.type() === 'error' ) errors.push( message.text() ); } ); + try { + await page.setContent( `

Edited copy

A tile
` ); + const before = await page.evaluate( () => document.body.innerHTML ); + await page.addScriptTag( { content: buildCanvasSandbox( [ { url: 'https://source.test/app.js', sha256: 'x', body: SOURCE } ] ) } ); + const pixels = () => page.evaluate( () => ( document.getElementById( 'surface' ) as HTMLCanvasElement ).toDataURL() ); + const blank = await pixels(); + await page.mouse.move( 100, 300 ); + await page.mouse.move( 220, 310 ); + expect( await page.evaluate( () => ( window as unknown as { __strokes: () => number } ).__strokes() ) ).toBeGreaterThan( 0 ); + const drawn = await pixels(); + expect( drawn ).not.toBe( blank ); + await page.click( '.tile' ); + await page.click( '#panel', { position: { x: 200, y: 5 } } ); + expect( await pixels() ).not.toBe( drawn ); + // Only the source-created canvas joined the page; all editable content is untouched. + const after = await page.evaluate( () => { + document.getElementById( 'extra' )?.remove(); + const surface = document.getElementById( 'surface' )!; + surface.removeAttribute( 'width' ); surface.removeAttribute( 'height' ); + return document.body.innerHTML; + } ); + expect( after ).toBe( before ); + expect( errors ).toEqual( [] ); + } finally { await page.close(); } + } ); +} ); diff --git a/src/lib/canvas-sandbox.ts b/src/lib/canvas-sandbox.ts new file mode 100644 index 00000000..bb863074 --- /dev/null +++ b/src/lib/canvas-sandbox.ts @@ -0,0 +1,90 @@ +/** + * Runs a source's own canvas code on a page whose text and layout are owned + * by editable content. The source code sees a membrane over the DOM: + * + * - canvas elements and their drawing contexts are fully live; + * - pointer, click and other event listeners attach to the real elements; + * - reads (text, geometry, computed style) return real values; + * - every write to a non-canvas node (text, attributes, style, classes, tree + * changes) is discarded, so editable content is never overwritten. + * + * Nothing about any particular site is assumed. Scripts that cannot run under + * the membrane simply fail the live pointer verification and stay unpromoted. + */ +const MEMBRANE = String.raw` +var realDoc=document,realWin=window,toReal=new WeakMap(),toProxy=new WeakMap(),listeners=new WeakMap(),inert=new Map(); +var MUTATORS=new Set(['appendChild','append','prepend','insertBefore','replaceChild','removeChild','remove','replaceWith','before','after','insertAdjacentHTML','insertAdjacentElement','insertAdjacentText','setAttribute','setAttributeNS','removeAttribute','removeAttributeNS','toggleAttribute','setAttributeNode','replaceChildren','normalize','click','focus','blur','scrollIntoView','requestFullscreen','attachShadow','animate','showModal','show','close','submit','reset']); +var WRAPPED=['target','currentTarget','srcElement','relatedTarget','view']; +function isCanvas(v){return typeof HTMLCanvasElement!=='undefined'&&v instanceof HTMLCanvasElement} +function isContext(v){return(typeof CanvasRenderingContext2D!=='undefined'&&v instanceof CanvasRenderingContext2D)||(typeof WebGLRenderingContext!=='undefined'&&v instanceof WebGLRenderingContext)||(typeof WebGL2RenderingContext!=='undefined'&&v instanceof WebGL2RenderingContext)} +function unwrap(v){return v&&(typeof v==='object'||typeof v==='function')&&toReal.has(v)?toReal.get(v):v} +function remember(real,proxy){toReal.set(proxy,real);toProxy.set(real,proxy);return proxy} +function isConstructor(name,fn){return typeof name==='string'&&/^[A-Z]/.test(name)&&typeof fn==='function'} +function call(fn,target){return function(){return wrap(fn.apply(target,Array.prototype.map.call(arguments,unwrap)))}} +function handler(fn,self){if(typeof fn!=='function'&&!(fn&&typeof fn.handleEvent==='function'))return fn;var known=listeners.get(fn);if(known)return known;var wrapped=function(event){return typeof fn==='function'?fn.call(self,wrap(event)):fn.handleEvent(wrap(event))};listeners.set(fn,wrapped);return wrapped} +function eventMethod(target,name,self){return function(type,fn,options){return target[name](type,handler(fn,self),options)}} +function wrap(v){ + if(!v||typeof v!=='object'||toReal.has(v))return v; + if(toProxy.has(v))return toProxy.get(v); + if(v===realDoc)return doc; + if(v===realWin)return win; + if(isContext(v))return live(v); + if(typeof Node!=='undefined'&&v instanceof Node)return isCanvas(v)?live(v):node(v); + if((typeof NodeList!=='undefined'&&v instanceof NodeList)||(typeof HTMLCollection!=='undefined'&&v instanceof HTMLCollection)){var list=Array.prototype.map.call(v,wrap);list.item=function(i){return list[i]||null};return list} + if(typeof Event!=='undefined'&&v instanceof Event)return reader(v,true); + if(typeof MutationRecord!=='undefined'&&v instanceof MutationRecord)return reader(v,false); + if(typeof IntersectionObserverEntry!=='undefined'&&v instanceof IntersectionObserverEntry)return reader(v,false); + if(typeof ResizeObserverEntry!=='undefined'&&v instanceof ResizeObserverEntry)return reader(v,false); + return v} +function reader(real,uncached){var proxy=new Proxy(real,{get:function(t,p){var v=Reflect.get(t,p,t);if(WRAPPED.indexOf(p)!==-1)return wrap(v);return typeof v==='function'?v.bind(t):wrap(v)},set:function(){return true}});return uncached?proxy:remember(real,proxy)} +function live(real){var proxy=new Proxy(real,{get:function(t,p){if(p==='addEventListener'||p==='removeEventListener')return eventMethod(t,p,proxy);var v=Reflect.get(t,p,t);if(typeof v==='function')return isConstructor(p,v)?v:call(v,t);return wrap(v)},set:function(t,p,v){t[p]=typeof p==='string'&&/^on/.test(p)?handler(v,proxy):unwrap(v);return true}});return remember(real,proxy)} +function ignoreSets(real,extra){return new Proxy(real,{get:function(t,p){if(extra&&extra[p])return extra[p](t);var v=Reflect.get(t,p,t);return typeof v==='function'?v.bind(t):v},set:function(){return true},deleteProperty:function(){return true}})} +var styleExtra={setProperty:function(){return function(){}},removeProperty:function(t){return function(name){return t.getPropertyValue(name)}}}; +var classExtra={add:function(){return function(){}},remove:function(){return function(){}},replace:function(){return function(){return false}},toggle:function(t){return function(token){return t.contains(token)}}}; +function node(real){var proxy=new Proxy(real,{ + get:function(t,p){ + if(p==='addEventListener'||p==='removeEventListener')return eventMethod(t,p,proxy); + if(p==='style')return ignoreSets(t.style,styleExtra); + if(p==='classList')return ignoreSets(t.classList,classExtra); + if(p==='dataset')return ignoreSets(t.dataset); + if(typeof p==='string'&&MUTATORS.has(p))return function(){ + var args=Array.prototype.map.call(arguments,unwrap); + // A canvas the source creates may still be attached to the page. + if(/^(appendChild|append|prepend|insertBefore)$/.test(p)&&args.length&&isCanvas(args[0])&&!args[0].isConnected){t[p].apply(t,args);return arguments[0]} + return /^(appendChild|removeChild|replaceChild|insertBefore)$/.test(p)?arguments[0]:undefined}; + var v=Reflect.get(t,p,t); + if(typeof v==='function')return isConstructor(p,v)?v:call(v,t); + return wrap(v)}, + set:function(t,p,v){if(typeof p==='string'&&/^on/.test(p))t[p]=handler(v,proxy);return true}, + deleteProperty:function(){return true}});return remember(real,proxy)} +var docOverrides={ + getElementById:function(id){var found=realDoc.getElementById(id);if(found)return wrap(found); + // Content the source would have created is absent; hand back a detached stand-in. + if(!inert.has(id)){var stand=realDoc.createElement('div');stand.id=String(id);inert.set(id,node(stand))}return inert.get(id)}, + write:function(){},writeln:function(){},open:function(){},close:function(){}}; +var doc=remember(realDoc,new Proxy(realDoc,{ + get:function(t,p){if(docOverrides[p])return docOverrides[p];if(p==='addEventListener'||p==='removeEventListener')return eventMethod(t,p,doc);var v=Reflect.get(t,p,t);if(typeof v==='function')return isConstructor(p,v)?v:call(v,t);return wrap(v)}, + set:function(t,p,v){if(typeof p==='string'&&/^on/.test(p))t[p]=handler(v,doc);return true}})); +function observer(Real){if(typeof Real!=='function')return Real;return function(callback,options){var instance=new Real(function(entries,self){return callback(Array.prototype.map.call(entries,wrap),api)},options);var api={observe:function(target,opts){return instance.observe(unwrap(target),opts)},unobserve:function(target){return instance.unobserve(unwrap(target))},disconnect:function(){return instance.disconnect()},takeRecords:function(){return Array.prototype.map.call(instance.takeRecords?instance.takeRecords():[],wrap)}};return api}} +var globals={getComputedStyle:function(element,pseudo){return realWin.getComputedStyle(unwrap(element),pseudo)},MutationObserver:observer(realWin.MutationObserver),ResizeObserver:observer(realWin.ResizeObserver),IntersectionObserver:observer(realWin.IntersectionObserver)}; +var win=remember(realWin,new Proxy(realWin,{ + get:function(t,p){if(p==='document')return doc;if(p==='window'||p==='self'||p==='globalThis'||p==='top'||p==='parent'||p==='frames')return win;if(globals[p])return globals[p];if(p==='addEventListener'||p==='removeEventListener')return eventMethod(t,p,win);var v=Reflect.get(t,p,t);if(typeof v==='function')return isConstructor(p,v)?v:call(v,t);return wrap(v)}, + set:function(t,p,v){t[p]=typeof p==='string'&&/^on/.test(p)?handler(v,win):unwrap(v);return true}})); +`; + +export interface SandboxedSource { url: string; sha256: string; body: string } + +/** A classic script: each source runs inside the membrane with page globals shadowed. */ +export function buildCanvasSandbox( sources: SandboxedSource[] ): string { + const bodies = sources.map( ( source ) => `// ${ source.url.replace( /[\r\n]/g, '' ) } sha256:${ source.sha256 }\n${ source.body }` ).join( '\n;\n' ); + return `(function(){ +${ MEMBRANE } +try{(function(document,window,self,globalThis,top,parent,getComputedStyle,MutationObserver,ResizeObserver,IntersectionObserver){ +${ bodies } +}).call(win,doc,win,win,win,win,win,globals.getComputedStyle,globals.MutationObserver,globals.ResizeObserver,globals.IntersectionObserver)}catch(error){console.error('[source canvas sandbox]',error)} +})(); +`; +} + +/** Sources worth sandboxing: their code draws on a canvas. */ +export const drawsOnCanvas = ( body: string ): boolean => /\.getContext\s*\(/.test( body ); diff --git a/src/lib/capture.ts b/src/lib/capture.ts index 06c1dddc..38c9f337 100644 --- a/src/lib/capture.ts +++ b/src/lib/capture.ts @@ -243,6 +243,10 @@ export async function captureWebsite( discoveryDiagnostics: inventory.diagnostics ?? [], } ); const unresolvedAnchors = readUnresolvedAnchors( outputDir ); + // Diagnosed dynamic pages need causal evidence, not an author-authored site + // recipe. Discovery remains explicit untranslated evidence until a portable + // implementation passes the independent source fidelity gate. + await ( await import( './behavior-discovery.js' ) ).discoverCapturedBehavior( outputDir ); const complete = summary.routesFailed === 0 && unresolvedAnchors.every( ( anchor ) => anchor.reason !== 'target route was not captured' ); diff --git a/src/lib/clock-behavior.ts b/src/lib/clock-behavior.ts new file mode 100644 index 00000000..46a97a74 --- /dev/null +++ b/src/lib/clock-behavior.ts @@ -0,0 +1,18 @@ +import type { SourceBehavior } from './screenshot/behavior-capture.js'; +import { timeDependentTargets } from './behavior-model.js'; + +export interface ClockBinding { selector: string; role: string; confidence: 'two_controlled_dates' } + +/** Match dynamic targets against controlled local time semantics, never their ID or source text labels. */ +export function learnClockBindings( first: SourceBehavior, second: SourceBehavior ): { bindings: ClockBinding[]; unsupported: string[] } { + const bindings: ClockBinding[] = []; + const unsupported: string[] = []; + for ( const selector of timeDependentTargets( first, second ) ) { + const a = first.startup.settledText?.[ selector ]?.trim(); + const b = second.startup.settledText?.[ selector ]?.trim(); + const roles = Object.keys( first.clockSamples ?? {} ).filter( ( role ) => a === first.clockSamples?.[ role ] && b === second.clockSamples?.[ role ] ); + if ( roles.length === 1 ) bindings.push( { selector, role: roles[ 0 ], confidence: 'two_controlled_dates' } ); + else unsupported.push( selector ); + } + return { bindings, unsupported }; +} diff --git a/src/lib/fidelity/candidate-motion.ts b/src/lib/fidelity/candidate-motion.ts index e2bcf7c9..55926b3a 100644 --- a/src/lib/fidelity/candidate-motion.ts +++ b/src/lib/fidelity/candidate-motion.ts @@ -5,7 +5,8 @@ import { sourceContextOptions } from '../browser-kit/browser-kit.js'; export interface MotionContract { widths: number[]; routes: Record< string, { - ready: { source: string; candidate: string }; + /** `sourceSettleMs` waits a learned startup duration for a source that exposes no readiness marker. */ + ready: { source: string; candidate: string; sourceSettleMs?: number }; text: string[]; /** Compare visibly present/hidden elements during startup and after readiness. */ visibility?: string[]; @@ -49,6 +50,9 @@ export function validateMotionContract( contract: MotionContract ): void { throw new Error( `Invalid motion contract route: ${ route }` ); } validateSelectors( [ probe.ready.source, probe.ready.candidate, ...probe.text, ...probe.canvases ] ); + if ( probe.ready.sourceSettleMs !== undefined && ( ! Number.isInteger( probe.ready.sourceSettleMs ) || probe.ready.sourceSettleMs < 0 || probe.ready.sourceSettleMs > 25_000 ) ) { + throw new Error( `Invalid readiness settle time: ${ route }` ); + } if ( probe.visibility ) { if ( ! Array.isArray( probe.visibility ) || probe.visibility.length > 16 ) throw new Error( `Invalid visibility probes: ${ route }` ); validateSelectors( probe.visibility ); @@ -66,7 +70,7 @@ export function validateMotionContract( contract: MotionContract ): void { } } -async function visit( page: Page, url: string, ready: string, text: string[], visibility: string[] ): Promise< { values: Record< string, string >; changes: Record< string, string[] >; observedAt: number; initialVisibility: Record< string, boolean >; finalVisibility: Record< string, boolean > } > { +async function visit( page: Page, url: string, ready: string, text: string[], visibility: string[], settleMs = 0 ): Promise< { values: Record< string, string >; changes: Record< string, string[] >; observedAt: number; initialVisibility: Record< string, boolean >; finalVisibility: Record< string, boolean > } > { await page.addInitScript( ( selectors ) => { const start = () => { const changes = Object.fromEntries( selectors.map( ( selector ) => [ selector, [] as string[] ] ) ); @@ -92,7 +96,7 @@ async function visit( page: Page, url: string, ready: string, text: string[], vi return [ selector, !! element && getComputedStyle( element ).display !== 'none' && getComputedStyle( element ).visibility !== 'hidden' ]; } ) ), visibility ); await page.waitForSelector( ready, { state: 'attached', timeout: 25_000 } ); - await page.waitForTimeout( 150 ); + await page.waitForTimeout( 150 + settleMs ); const result = await page.evaluate( ( selectors ) => { const trace = ( window as typeof window & { __dlaMotion?: { changes: Record< string, string[] >; sample: () => void } } ).__dlaMotion; trace?.sample(); @@ -156,6 +160,17 @@ async function replay( page: Page, click: { trigger: string; target: string }, r const trace = ( window as typeof window & { __dlaMotion?: { changes: Record< string, string[] >; sample: () => void } } ).__dlaMotion; if ( trace ) { trace.changes[ selector ] = []; trace.sample(); } }, click.target ); + await page.locator( click.trigger ).first().scrollIntoViewIfNeeded( { timeout: 3000 } ).catch( () => undefined ); + // A page may disable its controls while a previous sequence runs (for + // example `pointer-events:none` during a loading state). Click only once the + // trigger actually receives pointer input, as a visitor's click would. + await page.waitForFunction( ( selector ) => { + const trigger = document.querySelector( selector ); + const box = trigger?.getBoundingClientRect(); + if ( ! trigger || ! box || ! box.width || ! box.height ) return false; + const hit = document.elementFromPoint( box.left + box.width / 2, box.top + box.height / 2 ); + return !! hit && ( hit === trigger || trigger.contains( hit ) ); + }, click.trigger, { timeout: 10_000 } ).catch( () => undefined ); await page.locator( click.trigger ).first().click( { force: true, timeout: 5000 } ); const changed = await page.waitForFunction( ( { selector, previous } ) => document.querySelector( selector )?.textContent?.trim() !== previous, @@ -196,7 +211,7 @@ export async function verifyCandidateMotion( const candidatePage = await browser.newPage( { viewport: { width: viewport, height: 900 } } ); try { const [ original, copy ] = await Promise.all( [ - visit( sourcePage, source, contract.ready.source, contract.text, contract.visibility ?? [] ), + visit( sourcePage, source, contract.ready.source, contract.text, contract.visibility ?? [], contract.ready.sourceSettleMs ), visit( candidatePage, candidate, contract.ready.candidate, contract.text, contract.visibility ?? [] ), ] ); observations.text = { source: original, candidate: copy }; diff --git a/src/lib/fidelity/check.ts b/src/lib/fidelity/check.ts index 6e4916c9..aaef29a7 100644 --- a/src/lib/fidelity/check.ts +++ b/src/lib/fidelity/check.ts @@ -84,7 +84,9 @@ function overlayLabels( dismissed: DismissedOverlay[] ): string[] { export type ObservePair = ( sourceUrl: string, localUrl: string, - viewport: number + viewport: number, + /** A learned source startup duration; the source is observed only once it has settled. */ + sourceSettleMs?: number ) => Promise< { source: LayoutObservation; liberated: LayoutObservation; @@ -138,7 +140,7 @@ export type RouteScore = ViewportScore & { route: string }; export interface FidelityReport { /** Authored portable runtime was verified during this comparison, not inferred from source scripts. */ - portableMotion?: { verified: boolean; routes: string[] }; + portableMotion?: { verified: boolean; routes: string[]; origin: 'authored' | 'learned' }; cleanup?: { policy: CleanupPolicy; source: CleanupReport[] }; /** Overlays dismissed per side before measuring. Evidence, never a gate. */ overlays: OverlayRecord[]; @@ -915,7 +917,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F const portable = candidate || options.motionContract ? null : readPortableMotion( dirname( receiptPath ), websiteDir ); const motionContract = options.motionContract ?? portable?.contract; if ( motionContract ) { - if ( ( ! candidate && ! portable ) || options.observe ) throw new Error( 'Motion contract requires a live --candidate browser comparison or an authored portable runtime receipt' ); + if ( ( ! candidate && ! portable ) || options.observe ) throw new Error( 'Motion contract requires a live --candidate browser comparison or a portable runtime receipt' ); validateMotionContract( motionContract ); } let observe = options.observe; @@ -931,12 +933,12 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F throw error; } if ( ! observe ) { - observe = async ( sourceHref, localHref, viewport ) => { + observe = async ( sourceHref, localHref, viewport, sourceSettleMs = 0 ) => { if ( ! page ) throw new Error( 'browser page missing' ); await page.setViewportSize( { width: viewport, height: 900 } ); let sourcePng: Buffer | undefined; let liberatedPng: Buffer | undefined; - const source = await observePage( page, sourceHref, viewport, settleMs, null, receipt.cleanup?.policy, + const source = await observePage( page, sourceHref, viewport, Math.max( settleMs, sourceSettleMs ), null, receipt.cleanup?.policy, options.screenshots ? async () => { sourcePng = await page!.screenshot(); } : undefined ); if (receipt.cleanup) { const report = await readSourceCleanup(page); @@ -1012,7 +1014,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F ); for ( const width of widths ) { log( `[compare] ${ route } @ ${ width }px` ); - const pair = await observe( sourceHref, localHref, width ); + const pair = await observe( sourceHref, localHref, width, contract ? contract.ready.sourceSettleMs : undefined ); recordOverlays( route, width, sourceHref, localHref, pair ); const evidenceDir = join( dirname( receiptPath ), @@ -1034,7 +1036,8 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F checked.failures.push( `candidate retains advertising or source attribution (${ pair.candidateRetained } removable)` ); } if ( signals && ! candidateMotionVerified ) checked.failures.push( `source motion not reproduced by capture: ${ signals.join( ', ' ) }; candidate behavior unverified` ); - if ( candidateMotionVerified ) checked.notes.push( portable ? 'raw source/capture motion unreproduced; authored portable runtime independently verified at 390/768/1440px' : 'source/capture motion unreproduced; independent source/candidate interaction verified at 390/768/1440px' ); + for ( const residual of portable?.unsupported?.[ route ] ?? [] ) checked.failures.push( `source behavior not translated${ residual.selector ? ` (${ residual.selector })` : '' }: ${ residual.reason }` ); + if ( candidateMotionVerified ) checked.notes.push( portable ? `raw source/capture motion unreproduced; ${ portable.origin ?? 'authored' } portable runtime independently verified at 390/768/1440px` : 'source/capture motion unreproduced; independent source/candidate interaction verified at 390/768/1440px' ); const score: RouteScore = { route, viewport: width, @@ -1073,7 +1076,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F // conclusions from values that were zeroed rather than measured. if ( ! widths.includes( 390 ) ) { log( `[compare] ${ route } @ 390px interactivity` ); - const pair = await observe( sourceHref, localHref, 390 ); + const pair = await observe( sourceHref, localHref, 390, contract ? contract.ready.sourceSettleMs : undefined ); recordOverlays( route, 390, sourceHref, localHref, pair ); const dialogOnly = ( observation: LayoutObservation ): LayoutObservation => ( { ...observation, @@ -1107,7 +1110,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F score.failures.push( `source motion not reproduced by capture: ${ signals.join( ', ' ) }; candidate behavior unverified` ); score.pass = false; } - if ( candidateMotionVerified ) score.notes.push( portable ? 'raw capture motion unreproduced; authored portable runtime verified' : 'source/capture motion unreproduced; independent candidate interaction verified' ); + if ( candidateMotionVerified ) score.notes.push( portable ? `raw capture motion unreproduced; ${ portable.origin ?? 'authored' } portable runtime verified` : 'source/capture motion unreproduced; independent candidate interaction verified' ); score.notes.push( 'interactivity' ); scores.push( score ); } @@ -1157,7 +1160,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F selfConsistency, scores, ...( motionContract ? { motionEvidence } : {} ), - ...( portable ? { portableMotion: { verified: motionEvidence.length > 0 && motionEvidence.every( ( evidence ) => evidence.pass ), routes: Object.keys( portable.routes ) } } : {} ), + ...( portable ? { portableMotion: { verified: motionEvidence.length > 0 && motionEvidence.every( ( evidence ) => evidence.pass ), routes: Object.keys( portable.routes ), origin: portable.origin ?? 'authored' } } : {} ), ...summary, }; } diff --git a/src/lib/learned-motion-promotion.ts b/src/lib/learned-motion-promotion.ts new file mode 100644 index 00000000..a05fadd8 --- /dev/null +++ b/src/lib/learned-motion-promotion.ts @@ -0,0 +1,159 @@ +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import * as cheerio from 'cheerio'; +import type { Browser } from 'playwright'; +import { verifyCandidateMotion, type MotionContract, type MotionEvidence } from './fidelity/candidate-motion.js'; +import { checkSelfConsistency } from './fidelity/self-consistency.js'; +import type { LearnedMotion } from './learned-motion.js'; +import { MOTION_RUNTIME } from './motion-runtime.js'; +import { buildCanvasSandbox, drawsOnCanvas, type SandboxedSource } from './canvas-sandbox.js'; +import { PORTABLE_MOTION_SCHEMA, type PortableMotionReceipt } from './portable-motion.js'; +import { startStaticServer } from './replicate/local-site/static-server.js'; + +export interface LearnedPromotion { + promoted: boolean; + failures: string[]; + evidence: MotionEvidence[]; +} + +const VERIFIED_WIDTHS = [ 390, 768, 1440 ]; +const CANVAS_SIGNALS = [ 'canvas-2d', 'pointer-input', 'animation-frame' ]; +const escape = ( value: string ) => value.replace( /&/g, '&' ).replace( /"/g, '"' ).replace( /(); + for ( const step of learned.steps ) if ( step.clickSelector && ! clicks.has( step.clickSelector ) ) clicks.set( step.clickSelector, step.selector ); + if ( clock?.triggerSelector && ! clicks.has( clock.triggerSelector ) ) clicks.set( clock.triggerSelector, clock.hourSelector ); + return { + // The portable runtime marks completion; the source is given its learned settle time. + ready: { + source: 'body', + candidate: [ learned.steps.length ? ':has([data-blocks-engine-motion-ready])' : '', clock ? ':has([data-blocks-engine-clock-ready])' : '' ].reduce( ( selector, part ) => selector + part, 'html' ), + sourceSettleMs: learned.settleMs, + }, + text: [ + ...learned.steps.map( ( step ) => step.selector ), + ...( clock ? [ clock.hourSelector, clock.minuteSelector, clock.timezoneSelector, clock.ampmSelector, clock.dateSelector ].filter( ( value ): value is string => !! value ) : [] ), + ], + visibility: learned.steps.flatMap( ( step ) => ( step.revealSelectors ?? [] ).map( ( reveal ) => reveal.selector ) ), + ...( clock ? { clock: { hour: clock.hourSelector, minute: clock.minuteSelector, format: clock.hourCycle === '24' ? '24h' as const : '12h' as const } } : {} ), + clicks: [ ...clicks ].map( ( [ trigger, target ] ) => ( { trigger, target } ) ), + canvases: [], + }; +} + +/** + * Put learned motion on the portable site only after the staged copy + * reproduces it against the live source at 390/768/1440px. Behavior the + * vocabulary cannot express is recorded on the receipt and keeps failing + * plain comparison; it is never substituted. + */ +/** The page's captured same-origin scripts that draw on a canvas, byte-verified against the diagnosis. */ +function canvasSources( root: string, scripts: Array< { url: string; sha256: string } > ): SandboxedSource[] { + const manifestPath = join( root, 'resources', 'manifest.json' ); + if ( ! existsSync( manifestPath ) ) return []; + const manifest = JSON.parse( readFileSync( manifestPath, 'utf8' ) ) as { resources?: Record< string, { path?: string } > }; + const sources: SandboxedSource[] = []; + for ( const script of scripts ) { + const path = manifest.resources?.[ script.url ]?.path; + const file = path ? resolve( root, path ) : ''; + if ( ! file || ! file.startsWith( root + sep ) || ! existsSync( file ) ) continue; + const body = readFileSync( file, 'utf8' ); + if ( createHash( 'sha256' ).update( body ).digest( 'hex' ) === script.sha256 && drawsOnCanvas( body ) ) sources.push( { url: script.url, sha256: script.sha256, body } ); + } + return sources; +} + +export async function promoteLearnedMotion( directory: string, routes: Array< { url: string; learned: LearnedMotion } >, browser: Browser, options: { sandboxCanvas?: boolean } = {} ): Promise< LearnedPromotion > { + const root = resolve( directory ); + const failures: string[] = []; + const evidence: MotionEvidence[] = []; + if ( existsSync( join( root, 'portable-motion.json' ) ) ) return { promoted: false, failures: [ 'capture already has a portable motion receipt' ], evidence }; + const capture = JSON.parse( readFileSync( join( root, 'capture-receipt.json' ), 'utf8' ) ); + const websiteDir = resolve( root, capture.websiteRoot ?? 'website' ); + if ( ! websiteDir.startsWith( root + sep ) || ! existsSync( websiteDir ) ) return { promoted: false, failures: [ 'capture website root is missing' ], evidence }; + const interactivity = JSON.parse( readFileSync( join( root, 'source-interactivity.json' ), 'utf8' ) ) as { pages?: Array< { url: string; signals: string[]; scripts?: Array< { url: string; sha256: string } > } > }; + const sandboxFiles = new Map< string, Buffer >(); + const bytes = Buffer.from( MOTION_RUNTIME ); + const digest = createHash( 'sha256' ).update( bytes ).digest( 'hex' ); + const scriptPath = `motion/${ digest.slice( 0, 12 ) }-learned-motion.js`; + const stage = mkdtempSync( join( tmpdir(), 'dla-learned-motion-' ) ); + let server: Awaited< ReturnType< typeof startStaticServer > > | null = null; + try { + cpSync( websiteDir, stage, { recursive: true, force: true } ); + const receipt: PortableMotionReceipt = { schema: PORTABLE_MOTION_SCHEMA, origin: 'learned', contract: { widths: VERIFIED_WIDTHS, routes: {} }, routes: {}, unsupported: {} }; + const plans: Array< { route: string; url: string; signals: string[] } > = []; + for ( const { url, learned } of routes ) { + const page = ( interactivity.pages ?? [] ).find( ( row ) => row.url === url ); + const canvasResiduals = learned.unsupported.filter( ( row ) => row.kind === 'canvas' && row.selector ); + // The source's own drawing code, run behind the content membrane. + const sandboxed = options.sandboxCanvas && canvasResiduals.length ? canvasSources( root, page?.scripts ?? [] ) : []; + if ( ! learned.steps.length && ! learned.clock && ! sandboxed.length ) continue; + const route = new URL( url ).pathname; + const entry = route === new URL( capture.source?.url ?? url ).pathname ? { path: 'index.html' } : ( capture.routes ?? [] ).find( ( row: { url?: string } ) => row.url === url ); + const pagePath = entry?.path ? join( stage, String( entry.path ).replace( /^website\//, '' ) ) : ''; + if ( ! pagePath || ! resolve( pagePath ).startsWith( stage + sep ) || ! existsSync( pagePath ) ) { + failures.push( `${ route }: captured page not found` ); + continue; + } + const $ = cheerio.load( readFileSync( pagePath, 'utf8' ) ); + const contract = learnedMotionContract( learned ); + if ( sandboxed.length ) contract.canvases = canvasResiduals.map( ( row ) => row.selector! ); + const selectors = [ ...contract.text, ...( contract.visibility ?? [] ), ...contract.clicks.map( ( click ) => click.trigger ), ...contract.canvases ]; + const missing = selectors.filter( ( selector ) => $( selector ).length !== 1 ); + if ( missing.length ) { + failures.push( `${ route }: learned targets are not unique in the capture: ${ missing.join( ', ' ) }` ); + continue; + } + const scripts: PortableMotionReceipt[ 'routes' ][ string ][ 'scripts' ] = []; + if ( learned.steps.length || learned.clock ) { + if ( learned.steps.length ) $( 'body' ).append( `` ); + if ( learned.clock ) $( 'body' ).append( `` ); + // Declares itself the static interpreter of the markers; importers that + // lower the markers to blocks use those blocks' view scripts instead. + $( 'body' ).append( `` ); + scripts.push( { path: scriptPath, sha256: digest } ); + } + if ( sandboxed.length ) { + const sandbox = Buffer.from( buildCanvasSandbox( sandboxed ) ); + const sandboxDigest = createHash( 'sha256' ).update( sandbox ).digest( 'hex' ); + const sandboxPath = `motion/${ sandboxDigest.slice( 0, 12 ) }-source-canvas.js`; + sandboxFiles.set( sandboxPath, sandbox ); + $( 'body' ).append( `` ); + scripts.push( { path: sandboxPath, sha256: sandboxDigest } ); + } + writeFileSync( pagePath, $.html() ); + receipt.contract.routes[ route ] = contract; + receipt.routes[ route ] = { scripts, ...( sandboxed.length ? { sandboxedSource: sandboxed.map( ( { url: source, sha256 } ) => ( { url: source, sha256 } ) ) } : {} ) }; + const residual = sandboxed.length ? learned.unsupported.filter( ( row ) => ! canvasResiduals.includes( row ) ) : learned.unsupported; + if ( residual.length ) receipt.unsupported![ route ] = residual; + const signals = page?.signals ?? []; + // Without the sandbox, canvas stays a failing residual on the receipt and is not probed here. + plans.push( { route, url, signals: signals.filter( ( signal ) => sandboxed.length || ! canvasResiduals.length || ! CANVAS_SIGNALS.includes( signal ) ) } ); + } + if ( ! plans.length ) return { promoted: false, failures: failures.length ? failures : [ 'no learned motion to promote' ], evidence }; + mkdirSync( join( stage, 'motion' ), { recursive: true } ); + writeFileSync( join( stage, scriptPath ), bytes ); + for ( const [ path, content ] of sandboxFiles ) writeFileSync( join( stage, path ), content ); + const offline = checkSelfConsistency( stage, new Map( [ [ '/', 'index.html' ] ] ) ); + if ( ! offline.pass ) return { promoted: false, failures: [ ...failures, ...offline.findings.map( ( finding ) => finding.detail ) ], evidence }; + server = await startStaticServer( stage ); + for ( const plan of plans ) { + for ( const width of VERIFIED_WIDTHS ) { + evidence.push( await verifyCandidateMotion( browser, plan.route, width, plan.url, `${ server.url }${ plan.route }`, receipt.contract.routes[ plan.route ], plan.signals ) ); + } + } + for ( const row of evidence.filter( ( item ) => ! item.pass ) ) failures.push( `${ row.route } @ ${ row.viewport }px: ${ row.failures.join( '; ' ) }` ); + if ( failures.length ) return { promoted: false, failures, evidence }; + cpSync( stage, websiteDir, { recursive: true, force: true } ); + writeFileSync( join( root, 'portable-motion.json' ), JSON.stringify( receipt, null, 2 ) ); + return { promoted: true, failures, evidence }; + } finally { + await server?.close(); + rmSync( stage, { recursive: true, force: true } ); + } +} diff --git a/src/lib/learned-motion.test.ts b/src/lib/learned-motion.test.ts new file mode 100644 index 00000000..d7cd0092 --- /dev/null +++ b/src/lib/learned-motion.test.ts @@ -0,0 +1,105 @@ +import { createServer } from 'node:http'; +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureSourceBehavior, type SourceBehavior } from './screenshot/behavior-capture.js'; +import { learnMotion, type LearnedMotion } from './learned-motion.js'; +import { learnedMotionContract } from './learned-motion-promotion.js'; +import { MOTION_RUNTIME } from './motion-runtime.js'; +import { verifyCandidateMotion } from './fidelity/candidate-motion.js'; + +// Neutral source: names, copy, timings and glyphs share nothing with any real site. +const SOURCE = ` +
Harbor lights
+
Waiting
+
--:--
+`; + +describe( 'learned editable motion', () => { + let browser: Browser; + let origin: string; + let candidateHtml = ''; + const server = createServer( ( request, response ) => { + response.setHeader( 'content-type', 'text/html; charset=utf-8' ); + response.end( request.url?.startsWith( '/copy' ) ? candidateHtml : request.url?.startsWith( '/runtime.js' ) ? MOTION_RUNTIME : SOURCE ); + } ); + let first: SourceBehavior; + let learned: LearnedMotion; + beforeAll( async () => { + await new Promise< void >( ( resolve ) => server.listen( 0, '127.0.0.1', resolve ) ); + origin = `http://127.0.0.1:${ ( server.address() as { port: number } ).port }`; + browser = await chromium.launch(); + const a = await browser.newPage( { timezoneId: 'UTC' } ); + const b = await browser.newPage( { timezoneId: 'Europe/Berlin' } ); + first = await captureSourceBehavior( a, `${ origin }/`, { startupMs: 12000, quietMs: 1500, replayMs: 2500, fixedTime: '2031-02-03T04:17:00Z' } ); + const second = await captureSourceBehavior( b, `${ origin }/`, { startupMs: 12000, quietMs: 1500, maxClicks: 0, fixedTime: '2032-08-14T19:43:00Z' } ); + learned = learnMotion( first, second ); + // The capture's saved DOM: settled, script-free. + const settled = ( await a.content() ).replace( //g, '' ); + await Promise.all( [ a.close(), b.close() ] ); + candidateHtml = settled.replace( '', `` ); + }, 90_000 ); + afterAll( async () => { + await browser.close(); server.closeAllConnections(); + await new Promise< void >( ( resolve ) => server.close( () => resolve() ) ); + } ); + + it( 'infers sequence, pending dots, reveals, click replays and clock frames without configuration', () => { + expect( learned.unsupported ).toEqual( [] ); + expect( learned.steps.map( ( step ) => step.selector ) ).toEqual( [ '#headline', '#notice', '#footnote' ] ); + const [ headline, notice, footnote ] = learned.steps; + expect( headline ).toMatchObject( { clickSelector: '#headline-box', revealSelectors: [ { selector: '#headline-caption', hideWith: 'visibility' } ] } ); + expect( headline.delayMs ).toBeGreaterThan( 200 ); + expect( headline.replayDelayMs ).toBeGreaterThan( 40 ); + expect( notice ).toMatchObject( { pendingText: 'Waiting', pendingDots: true, clickSelector: '#notice-box' } ); + expect( notice.pendingIntervalMs ).toBeGreaterThanOrEqual( 150 ); + expect( footnote ).toMatchObject( { clickSelector: '#notice-box', revealSelectors: [ { selector: '#badge', hideWith: 'display' } ] } ); + // 40ms source cadence; timer jitter on a loaded machine only lengthens it. + for ( const step of learned.steps ) expect( step.intervalMs ).toBeGreaterThanOrEqual( 35 ); + expect( learned.clock ).toMatchObject( { + hourSelector: '#hh', minuteSelector: '#mm', timezoneSelector: '#zone', ampmSelector: '#meridiem', dateSelector: '#day', + triggerSelector: '#clock-box', hourCycle: '12', initialFrame: '--', stages: '~~,00', + } ); + // No editorial copy is carried in the program; it is read from the page. + expect( JSON.stringify( learned ) ).not.toMatch( /Harbor|Ready now|Online since/ ); + } ); + + it( 'reproduces the learned behavior from the saved DOM against the live source', async () => { + const evidence = await verifyCandidateMotion( browser, '/', 768, `${ origin }/`, `${ origin }/copy`, learnedMotionContract( learned ), [ 'timed-dom-update', 'click-input' ] ); + expect( evidence.failures ).toEqual( [] ); + }, 90_000 ); + + it( 'replays edited copy rather than captured source text', async () => { + const page = await browser.newPage(); + try { + const saved = candidateHtml; + candidateHtml = saved.replace( 'Harbor lights', 'Edited headline' ); + await page.goto( `${ origin }/copy` ); + await page.waitForFunction( () => document.querySelector( '#headline' )?.textContent === 'Edited headline' ); + candidateHtml = saved; + } finally { await page.close(); } + } ); + + it( 'reports canvas drawing as untranslated', async () => { + const page = await browser.newPage(); + try { + await page.route( `${ origin }/canvas`, ( route ) => route.fulfill( { contentType: 'text/html', body: '' } ) ); + const observed = await captureSourceBehavior( page, `${ origin }/canvas`, { startupMs: 200, maxClicks: 0 } ); + expect( learnMotion( observed, observed ).unsupported ).toEqual( [ expect.objectContaining( { selector: '#surface', reason: expect.stringContaining( 'arc' ) } ) ] ); + } finally { await page.close(); } + } ); +} ); diff --git a/src/lib/learned-motion.ts b/src/lib/learned-motion.ts new file mode 100644 index 00000000..caf2e7d4 --- /dev/null +++ b/src/lib/learned-motion.ts @@ -0,0 +1,234 @@ +import type { BehaviorTrace, SourceBehavior } from './screenshot/behavior-capture.js'; +import { learnTextReveals, timeDependentTargets, type LearnedTextReveal } from './behavior-model.js'; +import { learnClockBindings } from './clock-behavior.js'; + +/** + * Learned behavior expressed in the Blocks Engine authorable motion vocabulary + * (`data-blocks-engine-motion-steps` / `data-blocks-engine-live-clock`), so the + * same inert, editable configuration drives the portable site and the + * WordPress blocks. Values are inferred from browser evidence; nothing here is + * authored per site. Anything the vocabulary cannot express is reported. + */ +export const LEARNED_MOTION_SCHEMA = 'data-liberation/learned-motion/v1'; + +export interface MotionReveal { selector: string; hideWith: 'visibility' | 'display' } +export interface MotionStep { + selector: string; + delayMs: number; + intervalMs: number; + pendingText?: string; + pendingDots?: boolean; + pendingIntervalMs?: number; + clickSelector?: string; + replayDelayMs?: number; + revealSelectors?: MotionReveal[]; +} +export interface LiveClockConfig { + hourSelector: string; + minuteSelector: string; + timezoneSelector: string; + ampmSelector?: string; + dateSelector?: string; + triggerSelector?: string; + hourCycle: '12' | '24'; + locale: string; + initialFrame?: string; + stages: string; + startDelayMs: number; + stageDurationMs: number; + pauseMs: number; + dateDelayMs?: number; + characterIntervalMs?: number; +} +export interface UnsupportedBehavior { selector?: string; reason: string; kind?: 'canvas' } +export interface LearnedMotion { + schema: typeof LEARNED_MOTION_SCHEMA; + steps: MotionStep[]; + clock: LiveClockConfig | null; + /** Time from DOMContentLoaded until the source's learned startup behavior has settled. */ + settleMs: number; + unsupported: UnsupportedBehavior[]; +} + +type Frames = BehaviorTrace[ 'text' ][ string ]; +const median = ( values: number[] ): number => { + const sorted = [ ...values ].sort( ( a, b ) => a - b ); + return sorted.length ? sorted[ Math.floor( sorted.length / 2 ) ] : NaN; +}; +const within = ( value: number, min: number, max: number ) => Number.isFinite( value ) && value >= min && value <= max; +const distinct = ( frames: Frames = [] ) => new Set( frames.map( ( frame ) => frame.text.trim() ) ).size; + +/** First instant the reveal shows its complete value. */ +function revealEnd( frames: Frames, reveal: LearnedTextReveal, final: string ): number { + return frames.find( ( frame ) => frame.at >= reveal.startMs && frame.text === final )?.at ?? reveal.startMs + ( reveal.characters - 1 ) * reveal.intervalMs; +} + +/** A static message, optionally with the common 0–3 cycling-dot suffix, shown before a reveal. */ +function learnPending( frames: Frames, before: number, settled: string ): Pick< MotionStep, 'pendingText' | 'pendingDots' | 'pendingIntervalMs' > | 'unsupported' | null { + const pending = frames.filter( ( frame ) => frame.at < before && frame.text.trim() && frame.text !== settled ); + if ( ! pending.length ) return null; + const parsed = pending.map( ( frame ) => /^([\s\S]*?)(\.*)$/.exec( frame.text.trim() )! ); + const stem = parsed[ 0 ][ 1 ].trim(); + if ( ! stem || stem.length > 120 || parsed.some( ( match ) => match[ 1 ].trim() !== stem ) ) return 'unsupported'; + const dots = parsed.map( ( match ) => match[ 2 ].length ); + if ( new Set( dots ).size === 1 ) return dots[ 0 ] === 0 ? { pendingText: stem } : 'unsupported'; + const intervals = pending.slice( 1 ).map( ( frame, index ) => frame.at - pending[ index ].at ); + // Returning to the bare stem is the empty-dot state and may not be sampled as a separate frame. + const pendingIntervalMs = median( intervals ); + if ( Math.max( ...dots ) > 3 || ! within( pendingIntervalMs, 100, 2000 ) ) return 'unsupported'; + return { pendingText: stem, pendingDots: true, pendingIntervalMs }; +} + +function learnClock( first: SourceBehavior, second: SourceBehavior, unsupported: UnsupportedBehavior[] ): { clock: LiveClockConfig | null; targets: Set< string >; end: number } { + const { bindings, unsupported: unbound } = learnClockBindings( first, second ); + for ( const selector of unbound ) unsupported.push( { selector, reason: 'time-dependent text does not match a supported clock representation' } ); + const role = ( ...roles: string[] ) => bindings.find( ( binding ) => roles.includes( binding.role ) ); + const hour = role( 'hour12', 'hour24' ); + const minute = role( 'minute' ); + const timezone = role( 'gmtOffset' ); + const targets = new Set( bindings.map( ( binding ) => binding.selector ) ); + for ( const binding of bindings.filter( ( row ) => ! [ 'hour12', 'hour24', 'minute', 'gmtOffset', 'ampm', 'dateUpper' ].includes( row.role ) ) ) { + unsupported.push( { selector: binding.selector, reason: `clock representation ${ binding.role } has no portable clock field` } ); + } + if ( ! bindings.length ) return { clock: null, targets, end: 0 }; + if ( ! hour || ! minute || ! timezone ) { + for ( const binding of bindings ) unsupported.push( { selector: binding.selector, reason: 'clock lacks an hour, minute and timezone field' } ); + return { clock: null, targets, end: 0 }; + } + const settled = first.startup.settledText ?? {}; + // A click replay starts from a settled state, so its frame sequence is the cleanest evidence. + const replay = first.replays.find( ( row ) => distinct( row.trace.text[ hour.selector ] ) > 1 ); + const final = settled[ hour.selector ]; + const replayFrames = replay?.trace.text[ hour.selector ] ?? []; + const startupFrames = first.startup.text[ hour.selector ] ?? []; + const shown = ( frames: Frames ) => [ ...frames ].reverse().find( ( frame ) => frame.text === final ); + const stagesFrom = ( frames: Frames, from: number ) => frames.slice( from, frames.indexOf( shown( frames )! ) ); + const stageFrames = replay ? stagesFrom( replayFrames, 1 ) : stagesFrom( startupFrames, 1 ); + const minuteFrames = ( replay ? replay.trace.text[ minute.selector ] : first.startup.text[ minute.selector ] ) ?? []; + const minuteStages = minuteFrames.slice( 1, -1 ).map( ( frame ) => frame.text ); + const shownAt = shown( replay ? replayFrames : startupFrames )?.at; + const startupShownAt = shown( startupFrames )?.at; + const stageDurationMs = stageFrames.length > 1 ? median( stageFrames.slice( 1 ).map( ( frame, index ) => frame.at - stageFrames[ index ].at ) ) : 150; + // Timer jitter only ever delays a frame; a slightly negative remainder is zero. + const pauseMs = shownAt === undefined || ! stageFrames.length ? NaN : Math.max( 0, shownAt - stageFrames.at( -1 )!.at - stageDurationMs ); + const initial = startupFrames[ 0 ]; + const initialFrame = initial && initial.at < 50 && initial.text !== final ? initial.text : undefined; + const startDelayMs = startupShownAt === undefined ? NaN : Math.max( 0, startupShownAt - stageFrames.length * stageDurationMs - pauseMs ); + const stages = stageFrames.map( ( frame ) => frame.text ); + if ( stages.some( ( stage ) => ! stage || stage.length > 8 || stage.includes( ',' ) ) || stages.join( '\u0000' ) !== minuteStages.join( '\u0000' ) || + stages.length > 8 || ! within( stageDurationMs, 0, 1000 ) || ! within( pauseMs, 0, 1000 ) || ! within( startDelayMs, 0, 5000 ) ) { + for ( const binding of bindings ) unsupported.push( { selector: binding.selector, reason: 'clock startup frames do not fit the portable clock sequence' } ); + return { clock: null, targets, end: 0 }; + } + const clock: LiveClockConfig = { + hourSelector: hour.selector, minuteSelector: minute.selector, timezoneSelector: timezone.selector, + hourCycle: hour.role === 'hour24' ? '24' : '12', locale: first.locale ?? 'en-US', + stages: stages.join( ',' ), startDelayMs, stageDurationMs, pauseMs, + ...( initialFrame ? { initialFrame } : {} ), + ...( role( 'ampm' ) ? { ampmSelector: role( 'ampm' )!.selector } : {} ), + ...( replay ? { triggerSelector: replay.selector } : {} ), + }; + let end = startupShownAt ?? 0; + const date = role( 'dateUpper' ); + if ( date ) { + const trace = replay?.trace ?? first.startup; + const reveal = learnTextReveals( trace ).find( ( row ) => row.selector === date.selector ); + const dateDelayMs = reveal && shownAt !== undefined ? Math.max( 0, reveal.startMs - shownAt ) : NaN; + if ( ! reveal || ! within( dateDelayMs, 0, 1000 ) || ! within( reveal.intervalMs, 10, 500 ) ) { + unsupported.push( { selector: date.selector, reason: 'date text is not revealed after the clock in a supported sequence' } ); + } else { + Object.assign( clock, { dateSelector: date.selector, dateDelayMs, characterIntervalMs: reveal.intervalMs } ); + end = ( startupShownAt ?? 0 ) + dateDelayMs + reveal.characters * reveal.intervalMs; + } + } + return { clock, targets, end }; +} + +/** Infer an editable motion program from two controlled source observations. */ +export function learnMotion( first: SourceBehavior, second: SourceBehavior ): LearnedMotion { + const unsupported: UnsupportedBehavior[] = []; + const dynamic = new Set( timeDependentTargets( first, second ) ); + const { clock, targets: clockTargets, end: clockEnd } = learnClock( first, second, unsupported ); + const settled = first.startup.settledText ?? {}; + const steps: MotionStep[] = []; + let previous: { end: number; interval: number } | null = null; + for ( const reveal of learnTextReveals( first.startup ).filter( ( row ) => ! dynamic.has( row.selector ) && ! clockTargets.has( row.selector ) ) ) { + const frames = first.startup.text[ reveal.selector ]; + const final = settled[ reveal.selector ] ?? frames.at( -1 )!.text; + // The vocabulary is a sequence: each step waits after the previous step's last character. + const delayMs = previous ? reveal.startMs - previous.end - previous.interval : reveal.startMs; + // Typing that starts before the previous reveal completed is concurrent, not sequential. + if ( ( previous && reveal.startMs < previous.end ) || delayMs > 5000 || ! within( reveal.intervalMs, 10, 500 ) ) { + unsupported.push( { selector: reveal.selector, reason: 'text reveal overlaps another reveal or exceeds sequence timing bounds' } ); + continue; + } + const step: MotionStep = { selector: reveal.selector, delayMs: Math.max( 0, delayMs ), intervalMs: reveal.intervalMs }; + const pending = learnPending( frames, reveal.startMs, final ); + if ( pending === 'unsupported' ) unsupported.push( { selector: reveal.selector, reason: 'pending text before the reveal is not a static message with cycling dots' } ); + else if ( pending ) Object.assign( step, pending ); + steps.push( step ); + previous = { end: revealEnd( frames, reveal, final ), interval: reveal.intervalMs }; + } + if ( steps.length > 8 ) { + for ( const step of steps.splice( 8 ) ) unsupported.push( { selector: step.selector, reason: 'more than eight sequential text reveals' } ); + } + const bySelector = new Map( steps.map( ( step ) => [ step.selector, step ] ) ); + const transientCovered = steps.some( ( step ) => step.pendingDots ); + const explained = ( selector: string, trigger?: string ) => + ( bySelector.has( selector ) && ( ! trigger || bySelector.get( selector )!.clickSelector === trigger ) ) || + ( clockTargets.has( selector ) && ( ! trigger || clock?.triggerSelector === trigger ) ) || + ( ! ( selector in settled ) && transientCovered ); + for ( const replay of first.replays ) { + for ( const reveal of learnTextReveals( replay.trace ) ) { + const step = bySelector.get( reveal.selector ); + if ( ! step || clockTargets.has( reveal.selector ) ) continue; + if ( step.clickSelector && step.clickSelector !== replay.selector ) { + unsupported.push( { selector: reveal.selector, reason: 'text replays from more than one trigger' } ); + continue; + } + if ( ! within( reveal.startMs, 0, 5000 ) ) { + unsupported.push( { selector: reveal.selector, reason: 'click replay delay exceeds sequence timing bounds' } ); + continue; + } + Object.assign( step, { clickSelector: replay.selector, replayDelayMs: reveal.startMs } ); + } + for ( const [ selector, frames ] of Object.entries( replay.trace.text ) ) { + if ( distinct( frames ) > 1 && ! explained( selector, replay.selector ) ) unsupported.push( { selector, reason: `click on ${ replay.selector } changes text without a learned replay` } ); + } + } + for ( const [ selector, frames ] of Object.entries( first.startup.text ) ) { + if ( distinct( frames ) > 1 && ! explained( selector ) ) unsupported.push( { selector, reason: 'startup text change is not a recognized reveal, pending message or clock' } ); + } + // Elements hidden until a reveal starts. Descendants hidden by an ancestor follow it. + const revealed: Array< { selector: string; at: number; via?: string } > = []; + for ( const [ selector, states ] of Object.entries( first.startup.visibility ) ) { + if ( states.length < 2 ) continue; + if ( states.length !== 2 || states[ 0 ].visible || ! states[ 1 ].visible ) { + unsupported.push( { selector, reason: 'startup visibility change is not a single reveal' } ); + continue; + } + revealed.push( { selector, at: states[ 1 ].at, via: states[ 0 ].via } ); + } + for ( const row of revealed ) { + const step = steps.find( ( candidate ) => Math.abs( revealStart( first, candidate ) - row.at ) <= 150 ); + const direct = row.via === 'display' || row.via === 'visibility'; + if ( step && direct ) ( step.revealSelectors ??= [] ).push( { selector: row.selector, hideWith: row.via as MotionReveal[ 'hideWith' ] } ); + else if ( ! step || ! revealed.some( ( other ) => other !== row && Math.abs( other.at - row.at ) <= 50 && ( other.via === 'display' || other.via === 'visibility' ) ) ) { + unsupported.push( { selector: row.selector, reason: 'element becomes visible without a matching text reveal' } ); + } + } + for ( const [ selector, canvas ] of Object.entries( { ...first.startup.canvas, ...first.pointer.trace.canvas } ) ) { + unsupported.push( { selector, kind: 'canvas', reason: `canvas drawing algorithm is not translated (observed ${ Object.keys( canvas.methods ).join( ', ' ) })` } ); + } + const stepsEnd = steps.reduce( ( end, step ) => Math.max( end, revealEnd( first.startup.text[ step.selector ], learnTextReveals( first.startup ).find( ( row ) => row.selector === step.selector )!, settled[ step.selector ] ?? '' ) ), 0 ); + const unique = new Map( unsupported.map( ( row ) => [ `${ row.selector }\u0000${ row.reason }`, row ] ) ); + return { + schema: LEARNED_MOTION_SCHEMA, steps, clock, + settleMs: Math.min( 25000, Math.round( Math.max( stepsEnd, clockEnd ) * 1.25 + 1000 ) ), + unsupported: [ ...unique.values() ], + }; +} + +function revealStart( observed: SourceBehavior, step: MotionStep ): number { + return learnTextReveals( observed.startup ).find( ( row ) => row.selector === step.selector )?.startMs ?? NaN; +} diff --git a/src/lib/motion-runtime.ts b/src/lib/motion-runtime.ts new file mode 100644 index 00000000..25ef6d15 --- /dev/null +++ b/src/lib/motion-runtime.ts @@ -0,0 +1,53 @@ +/** + * Portable interpreter for the Blocks Engine motion marker vocabulary + * (`data-blocks-engine-motion-steps`, `data-blocks-engine-live-clock`). Blocks + * Engine lowers the same inert markers to editable blocks whose view scripts + * implement this contract in WordPress; this file is the static-site side. + * It reads editable text from the current DOM and embeds no page content. + */ +export const MOTION_RUNTIME = `(function(){ +var active=0,previousBusy=null; +function beginBusy(){if(active++===0){previousBusy=document.body.getAttribute('aria-busy');document.body.setAttribute('aria-busy','true')}} +function endBusy(){if(--active===0){if(previousBusy===null)document.body.removeAttribute('aria-busy');else document.body.setAttribute('aria-busy',previousBusy)}} +function select(selector){if(typeof selector!=='string'||selector.length>120)return null;try{return document.querySelector(selector)}catch(error){return null}} +function textTarget(selector){var element=select(selector);if(!element)return null;if(!element.children.length)return element;var child=element.firstElementChild;return element.children.length===1&&child.tagName==='P'&&!child.children.length?child:null} +function bounded(value,fallback,min,max){if(value===undefined||value===null||value==='')return fallback;var number=Number(value);return Number.isFinite(number)&&number>=min&&number<=max?number:fallback} +function wait(ms){return new Promise(function(resolve){setTimeout(resolve,ms)})} +function reveals(list){return(Array.isArray(list)?list:[]).slice(0,8).map(function(item){var element=item&&select(item.selector);if(!element)return null;var property=item.hideWith==='display'?'display':'visibility';return{element:element,property:property,hidden:property==='display'?'none':'hidden',original:element.style.getPropertyValue(property),priority:element.style.getPropertyPriority(property)}}).filter(Boolean)} +function hide(list){list.forEach(function(item){item.element.style.setProperty(item.property,item.hidden,'important')})} +function show(list){list.forEach(function(item){if(item.original)item.element.style.setProperty(item.property,item.original,item.priority);else item.element.style.removeProperty(item.property)})} +function mountSteps(marker){ + var input;try{input=JSON.parse(marker.getAttribute('data-blocks-engine-motion-steps')||'[]')}catch(error){return} + if(!Array.isArray(input))return; + var steps=input.slice(0,8).map(function(item){if(!item||typeof item!=='object')return null;var target=textTarget(item.selector);if(!target)return null;var text=target.textContent||'';if(text.length>500)return null; + return{target:target,text:Array.from(text),delay:bounded(item.delayMs,500,0,5000),interval:bounded(item.intervalMs,50,10,500),pending:typeof item.pendingText==='string'?item.pendingText.slice(0,120):'',pendingDots:item.pendingDots===true,pendingInterval:bounded(item.pendingIntervalMs,500,100,2000),replayDelay:bounded(item.replayDelayMs,0,0,5000),click:select(item.clickSelector),reveal:reveals(item.revealSelectors),timer:null,running:false}}).filter(Boolean); + if(!steps.length)return; + beginBusy(); + function showPending(step){if(step.timer!==null)clearInterval(step.timer);hide(step.reveal);step.target.textContent=step.pending;if(step.pending&&step.pendingDots){var dots=0;step.timer=setInterval(function(){dots=(dots+1)%4;step.target.textContent=step.pending+'.'.repeat(dots)},step.pendingInterval)}else step.timer=null} + function endPending(step){if(step.timer!==null)clearInterval(step.timer);step.timer=null;step.target.textContent=''} + async function play(step,first){if(step.running)return;step.running=true;showPending(step);var ms=first?step.delay:step.replayDelay;if(ms)await wait(ms);endPending(step);show(step.reveal);for(var index=0;index=0?'+':'')+offset+')'} + async function replay(){if(running)return;running=true;showingTime=false;if(date&&!date.children.length)date.textContent='';var interval=bounded(config.stageDurationMs,150,0,1000);for(var index=0;index } >; + routes: Record< string, { + scripts: Array< { path: string; sha256: string } >; + /** Source scripts run inside the canvas sandbox, identified by their captured bytes. */ + sandboxedSource?: Array< { url: string; sha256: string } >; + } >; + /** Observed source behavior the portable runtime does not reproduce, per route. */ + unsupported?: Record< string, Array< { selector?: string; reason: string } > >; } const sha = ( source: string | Buffer ) => createHash( 'sha256' ).update( source ).digest( 'hex' ); @@ -75,7 +83,7 @@ export async function authorPortableMotion( directory: string, recipe: PortableM let server: Awaited< ReturnType< typeof startStaticServer > > | null = null; try { cpSync( websiteDir, stage, { recursive: true, force: true } ); - const receipt: PortableMotionReceipt = { schema: PORTABLE_MOTION_SCHEMA, contract: recipe.contract, routes: {} }; + const receipt: PortableMotionReceipt = { schema: PORTABLE_MOTION_SCHEMA, origin: 'authored', contract: recipe.contract, routes: {} }; for ( const [ route, authored ] of Object.entries( recipe.routes ) ) { if ( ! recipe.contract.routes[ route ] || ! /^(?:\/|\/[a-z0-9-]+(?:\/[a-z0-9-]+)*\/?)$/i.test( route ) || ! authored || ! Array.isArray( authored.elements ) || ! Array.isArray( authored.markers ) || ! Array.isArray( authored.scripts ) || diff --git a/src/lib/screenshot/behavior-capture.test.ts b/src/lib/screenshot/behavior-capture.test.ts new file mode 100644 index 00000000..d9885dc9 --- /dev/null +++ b/src/lib/screenshot/behavior-capture.test.ts @@ -0,0 +1,62 @@ +import { createServer } from 'node:http'; +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureSourceBehavior } from './behavior-capture.js'; +import { timeDependentTargets } from '../behavior-model.js'; +import { learnClockBindings } from '../clock-behavior.js'; + +describe( 'automatic source behavior evidence', () => { + let browser: Browser; + let origin: string; + const server = createServer( ( request, response ) => { + const variant = request.url?.includes( 'second' ); + const field = variant ? 'renamed-message' : 'editorial'; + const button = variant ? 'different-trigger' : 'replay'; + const text = variant ? 'Other saved copy' : 'Readable text'; + response.setHeader( 'content-type', 'text/html; charset=utf-8' ); + response.end( `
Replay
${ text }` ); + } ); + beforeAll( async () => { + await new Promise< void >( resolve => server.listen( 0, '127.0.0.1', resolve ) ); + origin = `http://127.0.0.1:${ ( server.address() as { port: number } ).port }`; + browser = await chromium.launch(); + } ); + afterAll( async () => { + await browser.close(); server.closeAllConnections(); + await new Promise< void >( resolve => server.close( () => resolve() ) ); + } ); + for ( const variant of [ false, true ] ) { + it( `discovers renamed text/click and a ${ variant ? 'line' : 'circle' } canvas without a recipe`, async () => { + const page = await browser.newPage( { viewport: { width: 768, height: 900 } } ); + try { + const result = await captureSourceBehavior( page, `${ origin }/${ variant ? 'second' : '' }`, { startupMs: 500, replayMs: 500 } ); + const field = variant ? '#renamed-message' : '#editorial'; + const trigger = variant ? '#different-trigger' : '#replay'; + expect( result.status ).toBe( 'observed_untranslated' ); + expect( result.startup.text[ field ].length ).toBeGreaterThan( 3 ); + expect( result.replays.find( row => row.selector === trigger )?.trace.text[ field ].length ).toBeGreaterThan( 3 ); + expect( result.pointer.after[ '#drawing' ] ).not.toBe( result.pointer.before[ '#drawing' ] ); + const operations = result.pointer.trace.canvas[ '#drawing' ].methods; + expect( operations[ variant ? 'lineTo' : 'arc' ] ).toBeGreaterThan( 0 ); + expect( operations[ variant ? 'arc' : 'lineTo' ] ).toBeUndefined(); + } finally { await page.close(); } + } ); + } + it( 'proves time-dependent content under controlled Date without mistaking editorial text for a clock', async () => { + const firstPage = await browser.newPage(); + const secondPage = await browser.newPage(); + try { + const first = await captureSourceBehavior( firstPage, `${ origin }/`, { startupMs: 500, maxClicks: 0, fixedTime: '2031-02-03T04:17:00Z' } ); + const second = await captureSourceBehavior( secondPage, `${ origin }/`, { startupMs: 500, maxClicks: 0, fixedTime: '2032-08-14T19:43:00Z' } ); + expect( timeDependentTargets( first, second ) ).toEqual( [ '#dynamic-stamp' ] ); + const learned = learnClockBindings( first, second ); + expect( learned ).toEqual( { bindings: [ { selector: '#dynamic-stamp', role: 'iso', confidence: 'two_controlled_dates' } ], unsupported: [] } ); + } finally { await firstPage.close(); await secondPage.close(); } + } ); +} ); diff --git a/src/lib/screenshot/behavior-capture.ts b/src/lib/screenshot/behavior-capture.ts new file mode 100644 index 00000000..9d56e0a3 --- /dev/null +++ b/src/lib/screenshot/behavior-capture.ts @@ -0,0 +1,240 @@ +import type { Page } from 'playwright'; + +export const SOURCE_BEHAVIOR_SCHEMA = 'data-liberation/source-behavior/v1'; + +export interface BehaviorTrace { + text: Record< string, Array< { at: number; text: string } > >; + settledText?: Record< string, string >; + visibility: Record< string, Array< { at: number; visible: boolean; via?: 'display' | 'visibility' | 'layout' } > >; + events: Array< { selector: string; event: string } >; + canvas: Record< string, { methods: Record< string, number >; sample: Array< { method: string; args: unknown[] } > } >; + truncated: boolean; +} + +export interface SourceBehavior { + schema: typeof SOURCE_BEHAVIOR_SCHEMA; + url: string; + viewport: number; + startup: BehaviorTrace; + clockSamples?: Record< string, string >; + /** Document language used for locale-formatted values. */ + locale?: string; + pointer: { before: Record< string, string >; after: Record< string, string >; trace: BehaviorTrace }; + replays: Array< { selector: string; trace: BehaviorTrace } >; + /** Observing drawing activity does not identify its algorithm or establish reproducibility. */ + status: 'observed_untranslated'; +} + +/** Installs bounded instrumentation before source scripts execute. No source names, scripts or effects are assumed. */ +async function instrument( page: Page ): Promise< void > { + await page.addInitScript( () => { + type Trace = { + text: Record< string, Array< { at: number; text: string } > >; + visibility: Record< string, Array< { at: number; visible: boolean; via?: 'display' | 'visibility' | 'layout' } > >; + events: Array< { selector: string; event: string } >; + canvas: Record< string, { methods: Record< string, number >; sample: Array< { method: string; args: unknown[] } > } >; + truncated: boolean; + }; + const empty = (): Trace => ( { text: {}, visibility: {}, events: [], canvas: {}, truncated: false } ); + let trace = empty(); + let epoch = performance.now(); + let samples = 0; + let lastChange = performance.now(); + const listeners: Array< { selector: string; event: string } > = []; + function selector( target: EventTarget | Element ): string { + if ( target === window ) return 'window'; + if ( target === document ) return 'document'; + if ( ! ( target instanceof Element ) ) return ''; + if ( target.id && document.querySelectorAll( '#' + CSS.escape( target.id ) ).length === 1 ) return '#' + CSS.escape( target.id ); + const parts: string[] = []; + let element: Element | null = target; + while ( element && element !== document.documentElement && parts.length < 12 ) { + const tag = element.localName; + const siblings: Element[] = element.parentElement ? Array.from( element.parentElement.children ).filter( ( node ) => node.localName === tag ) : []; + parts.unshift( `${ tag }:nth-of-type(${ siblings.indexOf( element ) + 1 })` ); + element = element.parentElement; + } + return element === document.documentElement ? 'html>' + parts.join( '>' ) : ''; + } + const add = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function( event, listener, options ) { + if ( /^(?:click|pointermove|mousemove|touchmove)$/.test( event ) ) { + const key = selector( this ); + if ( key && ! listeners.some( ( row ) => row.selector === key && row.event === event ) ) { + if ( listeners.length < 64 ) listeners.push( { selector: key, event } ); + else trace.truncated = true; + } + } + return add.call( this, event, listener, options ); + }; + const canvasKeys = new WeakMap< HTMLCanvasElement, string >(); + const canvasKey = ( canvas: HTMLCanvasElement ) => { + let value = canvasKeys.get( canvas ); + if ( value === undefined ) canvasKeys.set( canvas, value = selector( canvas ) ); + return value; + }; + // Record drawing operations, not an invented effect label. Different source + // algorithms retain different evidence (e.g. arcs versus strokes). + for ( const method of [ 'clearRect', 'fillRect', 'strokeRect', 'beginPath', 'moveTo', 'lineTo', 'arc', 'ellipse', 'bezierCurveTo', 'quadraticCurveTo', 'fill', 'stroke', 'drawImage', 'fillText', 'putImageData' ] ) { + const prototype = CanvasRenderingContext2D.prototype as unknown as Record< string, ( ...args: unknown[] ) => unknown >; + const original = prototype[ method ]; + if ( typeof original !== 'function' ) continue; + prototype[ method ] = function( this: CanvasRenderingContext2D, ...args: unknown[] ) { + const name = canvasKey( this.canvas ); + if ( name ) { + if ( ! trace.canvas[ name ] && Object.keys( trace.canvas ).length < 8 ) trace.canvas[ name ] = { methods: {}, sample: [] }; + const record = trace.canvas[ name ]; + if ( record ) { + record.methods[ method ] = Math.min( 100000, ( record.methods[ method ] ?? 0 ) + 1 ); + if ( record.sample.length < 48 ) record.sample.push( { method, args: args.map( ( value ) => typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean' ? value : null ) } ); + } + } + return original.apply( this, args ); + }; + } + // Instrumentation must not slow the page it measures: record only the + // elements a mutation touched, and sweep the whole page on a slow timer + // for CSS-driven changes that produce no mutation record. + const keys = new WeakMap< Element, string >(); + const key = ( element: Element ) => { + let value = keys.get( element ); + if ( value === undefined || ( element.id && value !== '#' + CSS.escape( element.id ) ) ) keys.set( element, value = selector( element ) ); + return value; + }; + function record( element: Element, at: number ): void { + if ( element.closest( 'script,style,noscript,template' ) ) return; + const name = key( element ); + if ( ! name ) return; + // Leaves, plus mixed content such as a status message wrapping an + // animated child (`Loading..`): its full text is the + // user-visible phase even though a child element carries part of it. + const mixed = element.children.length > 0 && element.querySelectorAll( '*' ).length <= 3 && + Array.from( element.childNodes ).some( ( node ) => node.nodeType === Node.TEXT_NODE && ( node.textContent ?? '' ).trim() ); + if ( ( ! element.children.length || mixed ) && element.localName !== 'canvas' ) { + const text = element.textContent ?? ''; + if ( text.length <= 512 ) { + const history = trace.text[ name ] ??= []; + if ( history.at( -1 )?.text !== text ) { + lastChange = performance.now(); + if ( history.length < 128 ) history.push( { at, text } ); + else trace.truncated = true; + } + } + } + const style = getComputedStyle( element ); + const visible = style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0; + // The hiding mechanism matters: `visibility` keeps layout, `display` removes it. + const via = style.display === 'none' ? 'display' as const : style.visibility === 'hidden' ? 'visibility' as const : 'layout' as const; + const states = trace.visibility[ name ] ??= []; + if ( states.at( -1 )?.visible !== visible ) { + if ( states.length < 64 ) states.push( visible ? { at, visible } : { at, visible, via } ); + else trace.truncated = true; + } + } + function sample(): void { + if ( ! document.body ) return; + const nodes = Array.from( document.body.querySelectorAll( '*' ) ); + if ( nodes.length > 512 ) trace.truncated = true; + const at = Math.round( performance.now() - epoch ); + for ( const element of nodes.slice( 0, 512 ) ) record( element, at ); + } + function mutated( records: MutationRecord[] ): void { + if ( ++samples > 20000 ) { trace.truncated = true; return; } + const at = Math.round( performance.now() - epoch ); + const touched = new Set< Element >(); + for ( const row of records ) { + const target = row.target instanceof Element ? row.target : row.target.parentElement; + if ( ! target ) continue; + // Mixed-content ancestors change text when a child does. + for ( let element: Element | null = target, depth = 0; element && element !== document.body && depth < 3; element = element.parentElement, depth++ ) touched.add( element ); + // Style/class/child changes can hide or show a whole subtree. + if ( row.type !== 'characterData' ) for ( const child of Array.from( target.querySelectorAll( '*' ) ).slice( 0, 64 ) ) touched.add( child ); + } + for ( const element of touched ) record( element, at ); + } + const start = () => { + epoch = performance.now(); + sample(); + new MutationObserver( mutated ).observe( document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [ 'class', 'style', 'hidden', 'aria-busy' ] } ); + setInterval( sample, 500 ); + }; + add.call( document, 'DOMContentLoaded', start, { once: true } ); + ( window as unknown as { __dlaBehavior: unknown } ).__dlaBehavior = { + snapshot: () => { + sample(); trace.events = listeners.slice(); + const settledText: Record< string, string > = {}; + for ( const key of Object.keys( trace.text ) ) { + const element = document.querySelector( key ); + if ( element ) settledText[ key ] = element.textContent ?? ''; + } + return { ...trace, settledText }; + }, + /** Milliseconds since any observed text last changed. */ + idle: () => performance.now() - lastChange, + reset: () => { trace = empty(); epoch = performance.now(); samples = 0; sample(); }, + }; + } ); +} + +async function snapshot( page: Page ): Promise< BehaviorTrace > { + return page.evaluate( () => ( window as unknown as { __dlaBehavior: { snapshot: () => BehaviorTrace } } ).__dlaBehavior.snapshot() ); +} + +/** Bounded source-only probe. Its output is evidence for translation, never a fidelity pass. */ +export async function captureSourceBehavior( page: Page, url: string, options: { startupMs?: number; quietMs?: number; replayMs?: number; maxClicks?: number; fixedTime?: string } = {} ): Promise< SourceBehavior > { + const startupMs = Math.min( 15000, Math.max( 100, options.startupMs ?? 15000 ) ); + const replayMs = Math.min( 6000, Math.max( 100, options.replayMs ?? 5000 ) ); + const maxClicks = Math.min( 8, Math.max( 0, options.maxClicks ?? 8 ) ); + if ( options.fixedTime ) await page.clock.setFixedTime( new Date( options.fixedTime ) ); + await instrument( page ); + await page.goto( url, { waitUntil: 'domcontentloaded', timeout: 30000 } ); + // Startup ends once text has been quiet for `quietMs` (bounded by `startupMs`), + // so a slow machine does not truncate a sequence and a fast page is not over-waited. + const quietMs = Math.min( startupMs, Math.max( 500, options.quietMs ?? 3000 ) ); + await page.waitForTimeout( Math.min( startupMs, 1000 ) ); + await page.waitForFunction( ( quiet ) => ( window as unknown as { __dlaBehavior: { idle: () => number } } ).__dlaBehavior.idle() > quiet, quietMs, { timeout: Math.max( 1, startupMs - 1000 ), polling: 100 } ).catch( () => undefined ); + const startup = await snapshot( page ); + const clockSamples = await page.evaluate( () => { + const now = new Date(); + const two = ( value: number ) => String( value ).padStart( 2, '0' ); + const locale = document.documentElement.lang || 'en-US'; + const parts = new Intl.DateTimeFormat( locale, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' } ).formatToParts( now ); + const part = ( type: string ) => parts.find( ( value ) => value.type === type )?.value ?? ''; + const offset = -now.getTimezoneOffset() / 60; + return { + hour12: two( now.getHours() % 12 || 12 ), hour24: two( now.getHours() ), minute: two( now.getMinutes() ), + ampm: now.getHours() < 12 ? 'AM' : 'PM', iso: now.toISOString(), + gmtOffset: `(GMT ${ offset >= 0 ? '+' : '' }${ offset })`, + dateUpper: `${ part( 'weekday' ) }, ${ part( 'month' ) } ${ part( 'day' ) }, ${ part( 'year' ) }`.toUpperCase(), + }; + } ); + const bitmap = () => page.evaluate( () => Object.fromEntries( Array.from( document.querySelectorAll( 'canvas' ) ).slice( 0, 8 ).map( ( canvas, index ) => { + let image = ''; + try { image = canvas.toDataURL(); } catch { image = 'unreadable'; } + return [ canvas.id ? '#' + CSS.escape( canvas.id ) : `canvas:${ index }`, image ]; + } ) ) ); + const before = await bitmap(); + await page.evaluate( () => ( window as unknown as { __dlaBehavior: { reset: () => void } } ).__dlaBehavior.reset() ); + const viewport = page.viewportSize() ?? { width: 1440, height: 900 }; + await page.mouse.move( viewport.width * .25, viewport.height * .5 ); + await page.mouse.move( viewport.width * .6, viewport.height * .52 ); + await page.waitForTimeout( 200 ); + const pointer = { before, after: await bitmap(), trace: await snapshot( page ) }; + const replays: SourceBehavior[ 'replays' ] = []; + for ( const listener of startup.events.filter( ( row ) => row.event === 'click' && ! [ 'window', 'document' ].includes( row.selector ) ).slice( 0, maxClicks ) ) { + const target = page.locator( listener.selector ); + if ( await target.count() !== 1 || ! await target.isVisible() ) continue; + // Restrict probing to same-document handlers; navigation/submit controls + // remain owned by the established interaction capture primitives. + if ( await target.evaluate( ( element ) => !! element.closest( 'a[href],form' ) ) ) continue; + // Start each replay from a settled page so earlier phases do not leak into its trace. + await page.waitForFunction( () => ( window as unknown as { __dlaBehavior: { idle: () => number } } ).__dlaBehavior.idle() > 700, null, { timeout: replayMs } ).catch( () => undefined ); + await page.evaluate( () => ( window as unknown as { __dlaBehavior: { reset: () => void } } ).__dlaBehavior.reset() ); + await target.click( { force: true, timeout: 3000 } ); + await page.waitForTimeout( replayMs ); + if ( page.url() !== url ) break; + replays.push( { selector: listener.selector, trace: await snapshot( page ) } ); + } + const locale = await page.evaluate( () => document.documentElement.lang || 'en-US' ); + return { schema: SOURCE_BEHAVIOR_SCHEMA, url, viewport: viewport.width, startup, clockSamples, locale, pointer, replays, status: 'observed_untranslated' }; +} diff --git a/src/ui/compare.ts b/src/ui/compare.ts index df265618..3db7b54e 100644 --- a/src/ui/compare.ts +++ b/src/ui/compare.ts @@ -44,7 +44,7 @@ export async function runCompare( process.stdout.write( '\n' ); } for ( const evidence of report.motionEvidence ?? [] ) { - process.stdout.write( `${ evidence.route } ${ evidence.viewport }px source/${ report.portableMotion ? 'authored portable capture' : 'candidate' } interaction ${ evidence.pass ? 'ok' : `FAIL: ${ evidence.failures.join( '; ' ) }` } (raw captured script: removed)\n` ); + process.stdout.write( `${ evidence.route } ${ evidence.viewport }px source/${ report.portableMotion ? `${ report.portableMotion.origin } portable capture` : 'candidate' } interaction ${ evidence.pass ? 'ok' : `FAIL: ${ evidence.failures.join( '; ' ) }` } (raw captured script: removed)\n` ); } // Say what was measured, not just how it went. "Passed" over an unstated