From 6ef1dd542a2cbec79ed5e83d24093555c07453fa Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 29 Sep 2026 11:18:24 -0400 Subject: [PATCH] feat(capture): verify authored motion in portable HTML --- docs/candidate-motion.md | 52 +++++++++- src/cli.ts | 9 ++ src/index.ts | 2 + src/lib/fidelity/candidate-motion.ts | 28 +++++- src/lib/fidelity/check.ts | 24 +++-- src/lib/portable-motion.test.ts | 93 +++++++++++++++++ src/lib/portable-motion.ts | 143 +++++++++++++++++++++++++++ src/mcp-server.ts | 2 + src/ui/compare.ts | 2 +- src/ui/liberate.ts | 7 ++ 10 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 src/lib/portable-motion.test.ts create mode 100644 src/lib/portable-motion.ts diff --git a/docs/candidate-motion.md b/docs/candidate-motion.md index fc6b0c1a..c34e0cfc 100644 --- a/docs/candidate-motion.md +++ b/docs/candidate-motion.md @@ -39,10 +39,54 @@ visible replay phase. Unsupported source signals, missing probes, timeouts, and incomplete readiness fail closed. A contract proves only its named selectors, routes, and widths; list all interactions whose parity you intend to claim. -The result retains separate facts: `source/capture motion unreproduced` and an -independent `source/candidate interaction` verdict. Without the contract, -including when comparing the portable HTML capture, unreproduced source motion -remains a hard failure. With an independently passing candidate at 390, 768 and +The result retains separate facts: `raw source/capture motion unreproduced` and an +independent `source/candidate interaction` verdict. Without a verified authored +runtime, comparison of the portable HTML capture hard-fails on source motion. +With an independently passing candidate at 390, 768 and 1440px, a candidate comparison may pass; it **does not** make the motion-free portable artifact interactive. Per-width source/candidate traces and failures are saved to `compare/candidate-motion-evidence.json`. + +## Authoring behavior in the portable capture + +An author may also include a separately implemented, self-contained runtime in +the HTML artifact, without restoring the captured site's scripts. Pass +`--portable-motion ` to the normal liberation command, or call +`authorPortableMotion(runDirectory, recipe)` after capturing an existing run. +The recipe is a versioned `data-liberation/portable-motion/v1` object with the +same `contract` shown above and a `routes` map: + +```json +{ + "schema": "data-liberation/portable-motion/v1", + "contract": { "widths": [390, 768, 1440], "routes": { "/": { "ready": { "source": "body:not(.loading)", "candidate": "[data-motion-ready=true]" }, "text": ["#status"], "canvases": ["#drawing"], "clicks": [{"trigger":"#replay", "target":"#status"}] } } }, + "routes": { + "/": { + "elements": [{"selector":"#drawing", "attributes":{"data-motion-effect":"ripple"}}], + "busyHidden": ["#status-indicator"], + "markers": [{"attribute":"data-motion-sequence", "value":{"target":"#status"}}], + "scripts": [{"path":"/absolute/path/to/independently-authored.js", "sha256":"<64-character SHA-256>"}] + } + } +} +``` + +`elements` only adds `data-*` attributes to one existing captured element. +`busyHidden` is optional: a named element remains hidden while an authored +finite sequence sets `body[aria-busy=true]`. `markers` carry inert JSON +configuration for authored runtimes. The scripts must be independently authored, +outside the capture run, and hash-pinned. Any script whose hash matches captured +source code is rejected. No WordPress or destination runtime is shipped by Data +Liberation; the author chooses reusable scripts that read text from the DOM so +edits remain meaningful. Routes must have an unreproduced source-interactivity +diagnosis. The site is staged, checked offline and verified against the live +source at the contract's widths before the public `website/` tree is changed. +Failure leaves the original capture intact. The successful `portable-motion.json` +receipt stores relative script paths and hashes, not absolute source paths. + +Thereafter plain `data-liberation compare ` checks those hashes and +**reruns** the live source-versus-portable runtime contract. A missing/changed +script or failed startup, canvas, clock, visibility or click probe does not +inherit the earlier pass. The report distinguishes the removed raw source script +from the separately authored, proven interactive portable output. An exported +`website/` directory remains runnable without the CLI or any network access. diff --git a/src/cli.ts b/src/cli.ts index 90e9bbfe..90d98dfd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,8 @@ const HELP = ` interrupted, for browsing it. Liberation writes the site and exits without this. --no-learn-fluid Skip the width sweep and freeze the layout at one width. + --portable-motion Author a portable runtime from pinned independent + scripts and verify it against the live source before export. Learning is on by default: it keeps the copy reflowing like the source instead of pinning it to the capture width. @@ -193,12 +195,19 @@ if (args.includes('--help')) { } const { liberateSite } = await import('./ui/liberate.js'); + const portablePath = getArg('--portable-motion'); + if (args.includes('--portable-motion') && !portablePath) { + console.error('Error: --portable-motion requires a JSON recipe file.'); + process.exit(1); + } + const portableMotion = portablePath ? JSON.parse((await import('node:fs')).readFileSync(portablePath, 'utf8')) : undefined; const result = await liberateSite({ url, outputBase: getArg('--output') || resolveOutputBase(), resume: args.includes('--resume'), screenshots: args.includes('--screenshots'), learnFluid: !args.includes('--no-learn-fluid'), + portableMotion, serve: args.includes('--serve'), log: (message) => process.stderr.write(`${message}\n`), }); diff --git a/src/index.ts b/src/index.ts index a9a54b01..b3af51b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ export type { CaptureOptions, CaptureResult, CaptureProgress, CaptureDependencie export { checkFidelity } from './lib/fidelity/check.js'; export type { FidelityCheckOptions, FidelityReport, RouteScore, ObservePair } from './lib/fidelity/check.js'; export type { MotionContract, MotionEvidence } from './lib/fidelity/candidate-motion.js'; +export { authorPortableMotion } from './lib/portable-motion.js'; +export type { PortableMotionRecipe, PortableMotionReceipt } from './lib/portable-motion.js'; export type { DetectionResult, FullDetectionResult, diff --git a/src/lib/fidelity/candidate-motion.ts b/src/lib/fidelity/candidate-motion.ts index 286f09b3..e2bcf7c9 100644 --- a/src/lib/fidelity/candidate-motion.ts +++ b/src/lib/fidelity/candidate-motion.ts @@ -7,6 +7,8 @@ export interface MotionContract { routes: Record< string, { ready: { source: string; candidate: string }; text: string[]; + /** Compare visibly present/hidden elements during startup and after readiness. */ + visibility?: string[]; /** Volatile clock digits are compared to each page's own observation time, not across a minute boundary. */ clock?: { hour: string; minute: string; format: '12h' | '24h' }; clicks: Array< { trigger: string; target: string } >; @@ -47,6 +49,10 @@ 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.visibility ) { + if ( ! Array.isArray( probe.visibility ) || probe.visibility.length > 16 ) throw new Error( `Invalid visibility probes: ${ route }` ); + validateSelectors( probe.visibility ); + } if ( probe.clock ) { validateSelectors( [ probe.clock.hour, probe.clock.minute ] ); if ( ! probe.text.includes( probe.clock.hour ) || ! probe.text.includes( probe.clock.minute ) || ! [ '12h', '24h' ].includes( probe.clock.format ) ) { @@ -60,7 +66,7 @@ export function validateMotionContract( contract: MotionContract ): void { } } -async function visit( page: Page, url: string, ready: string, text: string[] ): Promise< { values: Record< string, string >; changes: Record< string, string[] >; observedAt: number } > { +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 > } > { await page.addInitScript( ( selectors ) => { const start = () => { const changes = Object.fromEntries( selectors.map( ( selector ) => [ selector, [] as string[] ] ) ); @@ -80,9 +86,14 @@ async function visit( page: Page, url: string, ready: string, text: string[] ): else start(); }, text ); await page.goto( url, { waitUntil: 'domcontentloaded', timeout: 30_000 } ); + await page.waitForTimeout( 200 ); + const initialVisibility = await page.evaluate( ( selectors ) => Object.fromEntries( selectors.map( ( selector ) => { + const element = document.querySelector( selector ); + 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 ); - return page.evaluate( ( selectors ) => { + const result = await page.evaluate( ( selectors ) => { const trace = ( window as typeof window & { __dlaMotion?: { changes: Record< string, string[] >; sample: () => void } } ).__dlaMotion; trace?.sample(); return { @@ -91,6 +102,11 @@ async function visit( page: Page, url: string, ready: string, text: string[] ): observedAt: Date.now(), }; }, text ); + const finalVisibility = await page.evaluate( ( selectors ) => Object.fromEntries( selectors.map( ( selector ) => { + const element = document.querySelector( selector ); + return [ selector, !! element && getComputedStyle( element ).display !== 'none' && getComputedStyle( element ).visibility !== 'hidden' ]; + } ) ), visibility ); + return { ...result, initialVisibility, finalVisibility }; } function validClock( observation: { values: Record< string, string >; observedAt: number }, clock: NonNullable< RouteContract[ 'clock' ] > ): boolean { @@ -180,10 +196,14 @@ 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 ), - visit( candidatePage, candidate, contract.ready.candidate, contract.text ), + visit( sourcePage, source, contract.ready.source, contract.text, contract.visibility ?? [] ), + visit( candidatePage, candidate, contract.ready.candidate, contract.text, contract.visibility ?? [] ), ] ); observations.text = { source: original, candidate: copy }; + for ( const selector of contract.visibility ?? [] ) { + if ( original.initialVisibility[ selector ] !== copy.initialVisibility[ selector ] ) failures.push( `startup visibility differs: ${ selector }` ); + if ( original.finalVisibility[ selector ] !== copy.finalVisibility[ selector ] ) failures.push( `settled visibility differs: ${ selector }` ); + } if ( contract.clock ) { if ( ! validClock( original, contract.clock ) ) failures.push( 'source clock is not visitor-local time' ); if ( ! validClock( copy, contract.clock ) ) failures.push( 'candidate clock is not visitor-local time' ); diff --git a/src/lib/fidelity/check.ts b/src/lib/fidelity/check.ts index 9926f9f8..1c7dd4d2 100644 --- a/src/lib/fidelity/check.ts +++ b/src/lib/fidelity/check.ts @@ -18,6 +18,7 @@ import { } from '../screenshot/page-helpers.js'; import { applySourceCleanup, readSourceCleanup, validateCleanupPolicy, type CleanupPolicy, type CleanupReport } from '../source-cleanup.js'; import { runFidelityChecks } from './checks.js'; +import { readPortableMotion } from '../portable-motion.js'; import { validateMotionContract, verifyCandidateMotion, type MotionContract, type MotionEvidence } from './candidate-motion.js'; import { probeDialogs } from './dialog-probe.js'; import { writePixelEvidence } from './evidence.js'; @@ -135,6 +136,8 @@ function candidateBase( candidateUrl: string ): string { 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[] }; cleanup?: { policy: CleanupPolicy; source: CleanupReport[] }; /** Overlays dismissed per side before measuring. Evidence, never a gate. */ overlays: OverlayRecord[]; @@ -807,9 +810,11 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F } const candidate = options.candidateUrl === undefined ? null : candidateBase( options.candidateUrl ); - if ( options.motionContract ) { - if ( ! candidate || options.observe ) throw new Error( 'Motion contract requires a live --candidate browser comparison' ); - validateMotionContract( options.motionContract ); + 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' ); + validateMotionContract( motionContract ); } let observe = options.observe; const browser = observe ? null : await (await import('playwright')).chromium.launch(); @@ -891,9 +896,9 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F const sourceHref = sources.get( route )!; const localHref = `${ candidate ?? server?.url ?? 'http://liberated.invalid' }${ route }`; const signals = unreproducedMotion.get( sourceHref ); - const contract = signals && options.motionContract?.routes[ route ]; + const contract = signals && motionContract?.routes[ route ]; if ( signals && contract && browser ) { - for ( const width of options.motionContract!.widths ) { + for ( const width of motionContract!.widths ) { log( `[compare] ${ route } @ ${ width }px source/candidate motion` ); motionEvidence.push( await verifyCandidateMotion( browser, route, width, sourceHref, localHref, contract, signals ) ); } @@ -925,7 +930,7 @@ 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( 'source/capture motion unreproduced; independent source/candidate interaction verified at 390/768/1440px' ); + 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' ); const score: RouteScore = { route, viewport: width, @@ -986,7 +991,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( 'source/capture motion unreproduced; independent candidate interaction verified' ); + if ( candidateMotionVerified ) score.notes.push( portable ? 'raw capture motion unreproduced; authored portable runtime verified' : 'source/capture motion unreproduced; independent candidate interaction verified' ); score.notes.push( 'interactivity' ); scores.push( score ); } @@ -1007,7 +1012,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F join(evidenceDir, 'overlay-evidence.json'), JSON.stringify({ schema: 'data-liberation/compare-overlays/v1', completed: comparisonCompleted, kinds: COMPARED_OVERLAY_KINDS, observations: overlays }, null, 2) ); - if ( options.motionContract ) writeFileSync( join( evidenceDir, 'candidate-motion-evidence.json' ), + if ( motionContract ) writeFileSync( join( evidenceDir, 'candidate-motion-evidence.json' ), JSON.stringify( { schema: 'data-liberation/candidate-motion/v1', completed: comparisonCompleted, observations: motionEvidence }, null, 2 ) ); } @@ -1035,7 +1040,8 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F routesCleanupUnproven: [ ...unproven ].sort(), selfConsistency, scores, - ...( options.motionContract ? { motionEvidence } : {} ), + ...( motionContract ? { motionEvidence } : {} ), + ...( portable ? { portableMotion: { verified: motionEvidence.length > 0 && motionEvidence.every( ( evidence ) => evidence.pass ), routes: Object.keys( portable.routes ) } } : {} ), ...summary, }; } diff --git a/src/lib/portable-motion.test.ts b/src/lib/portable-motion.test.ts new file mode 100644 index 00000000..4d7cc741 --- /dev/null +++ b/src/lib/portable-motion.test.ts @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium } from 'playwright'; +import { exportWebsiteCapture } from './capture-export.js'; +import { checkFidelity } from './fidelity/check.js'; +import { authorPortableMotion, PORTABLE_MOTION_SCHEMA, type PortableMotionRecipe } from './portable-motion.js'; +import { startStaticServer } from './replicate/local-site/static-server.js'; + +const sourceScript = `const canvas=document.getElementById('draw'); const lamp=document.getElementById('lamp'); lamp.style.display='none'; document.getElementById('status').textContent='Waiting'; canvas.getContext('2d'); requestAnimationFrame(function frame(){requestAnimationFrame(frame)}); addEventListener('mousemove',()=>canvas.getContext('2d').fillRect(5,5,15,15)); document.getElementById('replay').addEventListener('click',()=>{lamp.style.display='none';document.getElementById('status').textContent='Pending';setTimeout(()=>{document.getElementById('status').textContent='Ready';lamp.style.display='block'},150)});setTimeout(()=>{document.getElementById('status').textContent='Ready';lamp.style.display='block';document.body.classList.remove('loading')},700);`; +const authoredScript = `(function(){var status=document.querySelector('[data-motion-target]');var settled=status.textContent;var surface=document.querySelector('canvas[data-motion-surface]');document.body.setAttribute('aria-busy','true');status.textContent='Waiting';function complete(){status.textContent=settled;document.body.classList.remove('loading');document.body.removeAttribute('aria-busy')}window.setTimeout(complete,700);window.addEventListener('pointermove',function(){surface.getContext('2d').fillRect(5,5,15,15)});document.querySelector('[data-motion-replay]').addEventListener('click',function(){document.body.setAttribute('aria-busy','true');status.textContent='Pending';window.setTimeout(complete,150)});})();`; +const hash = ( value: string ) => createHash( 'sha256' ).update( value ).digest( 'hex' ); + +describe( 'authored portable motion after capture', () => { + const server = createServer( ( _request, response ) => { + response.setHeader( 'content-type', 'text/html' ); + response.end( `Animation

Ready

●` ); + } ); + let origin: string; + const directories: string[] = []; + 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 }/`; + } ); + afterAll( async () => { + server.closeAllConnections(); + await new Promise< void >( ( resolve ) => server.close( () => resolve() ) ); + for ( const directory of directories ) rmSync( directory, { recursive: true, force: true } ); + } ); + function capture(): { directory: string; script: string; recipe: PortableMotionRecipe } { + const directory = mkdtempSync( join( tmpdir(), 'dla-authored-portable-' ) ); + const external = mkdtempSync( join( tmpdir(), 'dla-independent-runtime-' ) ); + directories.push( directory, external ); + for ( const subdir of [ 'html', 'screenshots' ] ) mkdirSync( join( directory, subdir ) ); + writeFileSync( join( directory, 'html/homepage.html' ), `Animation

Ready

●` ); + writeFileSync( join( directory, 'screenshots/manifest.json' ), JSON.stringify( { version: 1, entries: { [ origin ]: { html: 'html/homepage.html' } } } ) ); + exportWebsiteCapture( { outputDir: directory, sourceUrl: origin, platform: 'generic', summary: {}, failures: [] } ); + const script = join( external, 'motion.js' ); + writeFileSync( script, authoredScript ); + const recipe: PortableMotionRecipe = { + schema: PORTABLE_MOTION_SCHEMA, + contract: { widths: [ 390, 768, 1440 ], routes: { '/': { ready: { source: 'body:not(.loading)', candidate: 'body:not(.loading)' }, text: [ '#status' ], visibility: [ '#lamp' ], canvases: [ '#draw' ], clicks: [ { trigger: '#replay', target: '#status' } ] } } }, + routes: { '/': { elements: [ { selector: '#draw', attributes: { 'data-motion-surface': 'true' } }, { selector: '#status', attributes: { 'data-motion-target': 'true' } }, { selector: '#replay', attributes: { 'data-motion-replay': 'true' } } ], busyHidden: [ '#lamp' ], markers: [], scripts: [ { path: script, sha256: hash( authoredScript ) } ] } }, + }; + return { directory, script, recipe }; + } + + it( 'keeps the source executable stripped and rejects a verbatim source script without writing the site', async () => { + const { directory, script, recipe } = capture(); + const html = join( directory, 'website/index.html' ); + const unchanged = readFileSync( html, 'utf8' ); + expect( unchanged ).not.toContain( sourceScript ); + writeFileSync( script, sourceScript ); + recipe.routes[ '/' ].scripts[ 0 ].sha256 = hash( sourceScript ); + await expect( authorPortableMotion( directory, recipe ) ).rejects.toThrow( /matches a captured source script/ ); + expect( readFileSync( html, 'utf8' ) ).toBe( unchanged ); + expect( existsSync( join( directory, 'portable-motion.json' ) ) ).toBe( false ); + } ); + + it( 'proves independent behavior and rechecks a portable artifact without a candidate flag', async () => { + const { directory, recipe } = capture(); + const unchanged = readFileSync( join( directory, 'website/index.html' ), 'utf8' ); + delete recipe.routes[ '/' ].busyHidden; + await expect( authorPortableMotion( directory, recipe ) ).rejects.toThrow( /startup visibility differs: #lamp/ ); + expect( readFileSync( join( directory, 'website/index.html' ), 'utf8' ) ).toBe( unchanged ); + recipe.routes[ '/' ].busyHidden = [ '#lamp' ]; + const authored = await authorPortableMotion( directory, recipe ); + expect( authored.pass ).toBe( true ); + const normal = await checkFidelity( { directory, widths: [ 390, 768, 1440 ], settleMs: 0 } ); + expect( normal.pass ).toBe( true ); + expect( normal.motionEvidence ).toHaveLength( 3 ); + const htmlPath = join( directory, 'website/index.html' ); + writeFileSync( htmlPath, readFileSync( htmlPath, 'utf8' ).replace( '

Ready

', '

Edited

' ) ); + const preview = await startStaticServer( join( directory, 'website' ) ); + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + await page.goto( preview.url, { waitUntil: 'domcontentloaded' } ); + await page.waitForSelector( 'body:not([aria-busy])' ); + expect( await page.locator( '#status' ).textContent() ).toBe( 'Edited' ); + await page.locator( '#replay' ).click(); + await page.waitForFunction( () => document.querySelector( '#status' )?.textContent === 'Edited' ); + expect( await page.locator( '#status' ).textContent() ).toBe( 'Edited' ); + } finally { await browser.close(); await preview.close(); } + const runtime = JSON.parse( readFileSync( join( directory, 'portable-motion.json' ), 'utf8' ) ); + const script = join( directory, 'website', runtime.routes[ '/' ].scripts[ 0 ].path ); + writeFileSync( script, '/* tampered */' ); + await expect( checkFidelity( { directory } ) ).rejects.toThrow( /motion script missing or changed/ ); + }, 180_000 ); +} ); diff --git a/src/lib/portable-motion.ts b/src/lib/portable-motion.ts new file mode 100644 index 00000000..29d73007 --- /dev/null +++ b/src/lib/portable-motion.ts @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { basename, join, resolve, sep } from 'node:path'; +import { tmpdir } from 'node:os'; +import * as cheerio from 'cheerio'; +import { checkFidelity, type FidelityReport } from './fidelity/check.js'; +import { validateMotionContract, type MotionContract } from './fidelity/candidate-motion.js'; +import { startStaticServer } from './replicate/local-site/static-server.js'; +import { checkSelfConsistency } from './fidelity/self-consistency.js'; + +export const PORTABLE_MOTION_SCHEMA = 'data-liberation/portable-motion/v1'; + +export interface PortableMotionRecipe { + schema: typeof PORTABLE_MOTION_SCHEMA; + contract: MotionContract; + routes: Record< string, { + /** Explicit authored behavior settings on existing, visible captured elements. */ + elements: Array< { selector: string; attributes: Record< string, string > } >; + /** Optional visible elements hidden until the authored finite sequence clears aria-busy. */ + busyHidden?: string[]; + /** Inert configuration; runtime code comes only from separately authored scripts. */ + markers: Array< { attribute: string; value: unknown } >; + /** Independently authored scripts, pinned by SHA-256. Captured source scripts are rejected. */ + scripts: Array< { path: string; sha256: string } >; + } >; +} + +export interface PortableMotionReceipt { + schema: typeof PORTABLE_MOTION_SCHEMA; + contract: MotionContract; + routes: Record< string, { scripts: Array< { path: string; sha256: string } > } >; +} + +const sha = ( source: string | Buffer ) => createHash( 'sha256' ).update( source ).digest( 'hex' ); +const safeName = ( value: string ) => /^[a-z0-9][a-z0-9.-]{0,79}$/i.test( value ) && ! value.includes( '..' ); +const escape = ( value: string ) => value.replace( /&/g, '&' ).replace( /"/g, '"' ).replace( / 128 * 1024 || sha( readFileSync( file ) ) !== script.sha256 ) { + throw new Error( `Portable motion script missing or changed: ${ script.path }` ); + } + } + } + return receipt; +} + +/** Extend a captured site using authored, hash-pinned runtime code; never reattach captured source scripts. */ +export async function authorPortableMotion( directory: string, recipe: PortableMotionRecipe ): Promise< FidelityReport > { + if ( recipe?.schema !== PORTABLE_MOTION_SCHEMA || ! recipe.routes || ! Object.keys( recipe.routes ).length ) throw new Error( 'Invalid portable motion recipe' ); + validateMotionContract( recipe.contract ); + const root = resolve( directory ); + if ( existsSync( join( root, 'portable-motion.json' ) ) ) throw new Error( 'This capture already has an authored portable motion receipt; recapture before authoring again' ); + 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 ) ) throw new Error( 'Capture website root is missing or escapes its run' ); + const sourceReport = capture.sourceInteractivity?.schema === 'data-liberation/source-interactivity/v1' && capture.sourceInteractivity?.path === 'source-interactivity.json' + ? JSON.parse( readFileSync( join( root, capture.sourceInteractivity.path ), 'utf8' ) ) + : null; + if ( ! Array.isArray( sourceReport?.pages ) ) throw new Error( 'Authored portable motion requires a diagnosed source-interactivity report' ); + const diagnosed = new Set< string >( sourceReport.pages.filter( ( page: { status?: string } ) => page.status === 'unreproduced' ).map( ( page: { url: string } ) => page.url ) ); + const sourceHashes = new Set< string >( ( sourceReport?.pages ?? [] ).flatMap( ( page: { scripts?: Array< { sha256: string } > } ) => ( page.scripts ?? [] ).map( ( script ) => script.sha256 ) ) ); + const stage = mkdtempSync( join( tmpdir(), 'dla-portable-motion-' ) ); + 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: {} }; + 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 ) || + authored.elements.length > 24 || authored.markers.length > 12 || authored.scripts.length > 8 || ! authored.scripts.length ) throw new Error( `Invalid portable motion route: ${ route }` ); + const sourceUrl = route === '/' ? capture.source?.url : ( capture.routes ?? [] ).find( ( entry: { url?: string } ) => new URL( entry.url ?? capture.source?.url ).pathname === route )?.url; + if ( ! sourceUrl || ! diagnosed.has( sourceUrl ) ) throw new Error( `Portable motion route lacks diagnosed source behavior: ${ route }` ); + const routePath = route === '/' ? 'index.html' : join( route.slice( 1 ), 'index.html' ); + const pagePath = join( stage, routePath ); + if ( ! existsSync( pagePath ) ) throw new Error( `Portable motion route was not captured: ${ route }` ); + const $ = cheerio.load( readFileSync( pagePath, 'utf8' ) ); + for ( const element of authored.elements ) { + if ( ! element || typeof element.selector !== 'string' || element.selector.length > 120 || $( element.selector ).length !== 1 || ! element.attributes || Object.keys( element.attributes ).length > 8 ) { + throw new Error( `Authored motion element must resolve exactly once: ${ element?.selector }` ); + } + for ( const [ name, value ] of Object.entries( element.attributes ) ) { + if ( ! /^data-[a-z0-9-]{1,80}$/.test( name ) || typeof value !== 'string' || value.length > 256 ) throw new Error( `Invalid portable motion element attribute: ${ name }` ); + $( element.selector ).attr( name, value ); + } + } + if ( authored.busyHidden ) { + if ( ! Array.isArray( authored.busyHidden ) || authored.busyHidden.length > 12 || authored.busyHidden.some( ( selector ) => ! /^#[a-z0-9_-]{1,80}$/i.test( selector ) || $( selector ).length !== 1 ) ) throw new Error( 'Invalid busy-hidden authored element' ); + $( 'head' ).append( `` ); + } + for ( const marker of authored.markers ) { + if ( ! /^data-[a-z0-9-]{1,80}$/.test( marker?.attribute ?? '' ) ) throw new Error( 'Invalid portable motion marker' ); + const json = JSON.stringify( marker.value ); + if ( ! json || Buffer.byteLength( json ) > 8192 ) throw new Error( 'Portable motion marker exceeds 8192 bytes' ); + $( 'body' ).append( `` ); + } + const scripts: PortableMotionReceipt[ 'routes' ][ string ][ 'scripts' ] = []; + for ( const script of authored.scripts ) { + if ( ! script || ! safeName( basename( script.path ) ) || ! /^[a-f0-9]{64}$/i.test( script.sha256 ) ) throw new Error( 'Invalid authored portable motion script' ); + const authoredPath = realpathSync( resolve( script.path ) ); + if ( authoredPath.startsWith( root + sep ) ) throw new Error( 'Authored script must not come from the captured source run' ); + const bytes = readFileSync( authoredPath ); + const digest = sha( bytes ); + if ( bytes.length > 128 * 1024 || digest !== script.sha256 || sourceHashes.has( digest ) ) throw new Error( 'Authored script differs from its pin or matches a captured source script' ); + const path = `motion/${ digest.slice( 0, 12 ) }-${ basename( script.path ) }`; + mkdirSync( join( stage, 'motion' ), { recursive: true } ); + writeFileSync( join( stage, path ), bytes ); + $( 'body' ).append( `` ); + scripts.push( { path, sha256: digest } ); + } + receipt.routes[ route ] = { scripts }; + writeFileSync( pagePath, $.html() ); + } + const routes = new Map< string, string >( [ [ '/', 'index.html' ] ] ); + for ( const entry of capture.routes ?? [] ) { + if ( typeof entry.url !== 'string' || typeof entry.path !== 'string' ) continue; + try { routes.set( new URL( entry.url ).pathname, entry.path.replace( /^website\//, '' ) ); } catch { /* Malformed source URLs are never routes. */ } + } + const offline = checkSelfConsistency( stage, routes ); + if ( ! offline.pass ) throw new Error( `Authored portable site fails offline checks: ${ offline.findings.map( ( finding ) => finding.detail ).join( '; ' ) }` ); + server = await startStaticServer( stage ); + const report = await checkFidelity( { directory: root, candidateUrl: server.url, motionContract: recipe.contract, widths: recipe.contract.widths } ); + if ( ! report.pass ) throw new Error( `Portable motion did not reproduce the source: ${ report.scores.flatMap( ( score ) => score.failures ).join( '; ' ) }; ${ report.motionEvidence?.flatMap( ( row ) => row.failures ).join( '; ' ) }` ); + // Successful browser proof precedes every change to the public website. + cpSync( stage, websiteDir, { recursive: true, force: true } ); + writeFileSync( join( root, 'portable-motion.json' ), JSON.stringify( receipt, null, 2 ) ); + return report; + } finally { + await server?.close(); + rmSync( stage, { recursive: true, force: true } ); + } +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 49a48a58..de60ab53 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -60,6 +60,7 @@ const TOOLS = [ outputDir: { type: 'string', description: 'Output base directory. Defaults to ~/data-liberation.' }, resume: { type: 'boolean', description: 'Reuse artifacts already on disk instead of recapturing.' }, screenshots: { type: 'boolean', description: 'Also capture full-page and scrolled PNGs.' }, + portableMotion: { type: 'object', description: 'Optional author-supplied, hash-pinned portable motion recipe verified in the browser before export.' }, }, required: ['url'], }, @@ -128,6 +129,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { outputBase: typeof args.outputDir === 'string' ? args.outputDir : resolveOutputBase(), resume: args.resume === true, screenshots: args.screenshots === true, + portableMotion: args.portableMotion && typeof args.portableMotion === 'object' ? args.portableMotion as import('./lib/portable-motion.js').PortableMotionRecipe : undefined, // A tool call has no terminal to hold, so it never serves. serve: false, log, diff --git a/src/ui/compare.ts b/src/ui/compare.ts index 36ebb738..df265618 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/candidate interaction ${ evidence.pass ? 'ok' : `FAIL: ${ evidence.failures.join( '; ' ) }` } (static capture: unreproduced)\n` ); + 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` ); } // Say what was measured, not just how it went. "Passed" over an unstated diff --git a/src/ui/liberate.ts b/src/ui/liberate.ts index 0bcba03b..2f11c97e 100644 --- a/src/ui/liberate.ts +++ b/src/ui/liberate.ts @@ -11,6 +11,7 @@ import type { UnresolvedAnchor } from '../lib/capture.js'; import { siteOutputDir } from '../lib/paths.js'; import { startStaticServer } from '../lib/replicate/local-site/static-server.js'; import type { StaticServer } from '../lib/replicate/local-site/static-server.js'; +import { authorPortableMotion, type PortableMotionRecipe } from '../lib/portable-motion.js'; export interface LiberateOptions { url: string; @@ -23,6 +24,8 @@ export interface LiberateOptions { serve?: boolean; /** Learn responsive sizing across widths rather than freezing one. Default: true. */ learnFluid?: boolean; + /** Optional authored runtime; installed only after live source/portable behavior comparison passes. */ + portableMotion?: PortableMotionRecipe; log?: ( message: string ) => void; } @@ -62,6 +65,10 @@ export async function liberateSite( options: LiberateOptions ): Promise< Liberat } ); const websiteDir = join( outputDir, 'website' ); + if ( options.portableMotion && capture.complete ) { + log( '[liberate] verifying authored portable motion against the live source' ); + await authorPortableMotion( outputDir, options.portableMotion ); + } return { outputDir, websiteDir,