From 398cc3648b4567813aea82ca4031b6e56857a138 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 26 Apr 2026 11:44:16 +0300 Subject: [PATCH 1/5] debugging suite - first leg --- packages/interact-debug/package.json | 81 +++ packages/interact-debug/src/artifact.ts | 540 +++++++++++++++ packages/interact-debug/src/cli.ts | 2 + packages/interact-debug/src/index.ts | 42 ++ packages/interact-debug/src/log/index.ts | 2 + packages/interact-debug/src/log/logger.ts | 145 ++++ .../interact-debug/src/playwright/index.ts | 2 + packages/interact-debug/src/types.ts | 137 ++++ .../src/validate/antiPatterns.ts | 100 +++ .../src/validate/compatibilityValidator.ts | 196 ++++++ .../src/validate/configValidator.ts | 407 ++++++++++++ .../interact-debug/src/validate/helpers.ts | 158 +++++ packages/interact-debug/src/validate/index.ts | 98 +++ .../src/validate/integrationValidator.ts | 223 +++++++ .../src/validate/referenceValidator.ts | 170 +++++ .../src/validate/registryValidator.ts | 126 ++++ .../interact-debug/test/antiPatterns.spec.ts | 155 +++++ packages/interact-debug/test/artifact.spec.ts | 297 +++++++++ .../test/compatibilityValidator.spec.ts | 203 ++++++ .../test/configValidator.spec.ts | 622 ++++++++++++++++++ .../test/integrationValidator.spec.ts | 158 +++++ packages/interact-debug/test/logger.spec.ts | 149 +++++ .../test/referenceValidator.spec.ts | 176 +++++ .../test/registryValidator.spec.ts | 99 +++ packages/interact-debug/tsconfig.build.json | 24 + packages/interact-debug/tsconfig.json | 20 + packages/interact-debug/vite.config.ts | 44 ++ packages/interact-debug/vitest.config.ts | 8 + yarn.lock | 78 ++- 29 files changed, 4459 insertions(+), 3 deletions(-) create mode 100644 packages/interact-debug/package.json create mode 100644 packages/interact-debug/src/artifact.ts create mode 100644 packages/interact-debug/src/cli.ts create mode 100644 packages/interact-debug/src/index.ts create mode 100644 packages/interact-debug/src/log/index.ts create mode 100644 packages/interact-debug/src/log/logger.ts create mode 100644 packages/interact-debug/src/playwright/index.ts create mode 100644 packages/interact-debug/src/types.ts create mode 100644 packages/interact-debug/src/validate/antiPatterns.ts create mode 100644 packages/interact-debug/src/validate/compatibilityValidator.ts create mode 100644 packages/interact-debug/src/validate/configValidator.ts create mode 100644 packages/interact-debug/src/validate/helpers.ts create mode 100644 packages/interact-debug/src/validate/index.ts create mode 100644 packages/interact-debug/src/validate/integrationValidator.ts create mode 100644 packages/interact-debug/src/validate/referenceValidator.ts create mode 100644 packages/interact-debug/src/validate/registryValidator.ts create mode 100644 packages/interact-debug/test/antiPatterns.spec.ts create mode 100644 packages/interact-debug/test/artifact.spec.ts create mode 100644 packages/interact-debug/test/compatibilityValidator.spec.ts create mode 100644 packages/interact-debug/test/configValidator.spec.ts create mode 100644 packages/interact-debug/test/integrationValidator.spec.ts create mode 100644 packages/interact-debug/test/logger.spec.ts create mode 100644 packages/interact-debug/test/referenceValidator.spec.ts create mode 100644 packages/interact-debug/test/registryValidator.spec.ts create mode 100644 packages/interact-debug/tsconfig.build.json create mode 100644 packages/interact-debug/tsconfig.json create mode 100644 packages/interact-debug/vite.config.ts create mode 100644 packages/interact-debug/vitest.config.ts diff --git a/packages/interact-debug/package.json b/packages/interact-debug/package.json new file mode 100644 index 00000000..76e439c7 --- /dev/null +++ b/packages/interact-debug/package.json @@ -0,0 +1,81 @@ +{ + "name": "@wix/interact-debug", + "version": "0.1.0", + "description": "Debug suite for @wix/interact: validate, inspect, and score interaction implementations.", + "license": "MIT", + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/es/index.js", + "require": "./dist/cjs/index.js" + }, + "./playwright": { + "types": "./dist/types/playwright/index.d.ts", + "import": "./dist/es/playwright.js", + "require": "./dist/cjs/playwright.js" + } + }, + "bin": "./dist/es/cli.js", + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "build": "rimraf dist && vite build && npm run build:types", + "build:types": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "keywords": [ + "animation", + "debug", + "interact", + "validation" + ], + "author": { + "name": "wow!Team", + "email": "wow-dev@wix.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wix/interact.git" + }, + "bugs": { + "url": "https://github.com/wix/interact/issues" + }, + "homepage": "https://github.com/wix/interact#readme", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0", + "@wix/interact": ">=2.0.0", + "@wix/motion": ">=2.0.0", + "@wix/motion-presets": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + }, + "@wix/motion-presets": { + "optional": true + } + }, + "dependencies": { + "jsdom": "^24.0.0" + }, + "devDependencies": { + "@types/jsdom": "^21.1.7", + "@vitest/coverage-v8": "^4.0.14", + "@wix/interact": "workspace:*", + "@wix/motion": "workspace:*", + "@wix/motion-presets": "workspace:*", + "rimraf": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.14" + } +} diff --git a/packages/interact-debug/src/artifact.ts b/packages/interact-debug/src/artifact.ts new file mode 100644 index 00000000..d0fe7c60 --- /dev/null +++ b/packages/interact-debug/src/artifact.ts @@ -0,0 +1,540 @@ +import { JSDOM } from 'jsdom'; +import type { InteractConfig } from './types'; +import type { InteractArtifact, ArtifactInput, FrameworkType } from './types'; + +/** + * Parse any supported input into a unified InteractArtifact. + */ +export async function parseArtifact(input: ArtifactInput): Promise { + switch (input.type) { + case 'separated': + return parseSeparated(input); + case 'mixed': + return parseMixed(input.source); + case 'url': + return parseUrl(input.url); + case 'directory': + return parseDirectory(input.path); + } +} + +// --------------------------------------------------------------------------- +// Separated +// --------------------------------------------------------------------------- + +function parseSeparated(input: { + config: InteractConfig; + html: string; + css?: string; + js?: string; +}): InteractArtifact { + const framework = input.js ? detectFramework(input.js) : undefined; + const registeredEffects = input.js ? extractRegisteredEffects(input.js) : undefined; + + return { + config: input.config, + html: input.html, + css: input.css, + js: input.js, + framework, + registeredEffects, + sourceType: 'separated', + }; +} + +// --------------------------------------------------------------------------- +// Mixed blob +// --------------------------------------------------------------------------- + +function parseMixed(source: string): InteractArtifact { + const dom = new JSDOM(source); + const { document } = dom.window; + + const css = extractCss(document); + const js = extractJs(document); + const config = extractConfig(js, document); + const html = extractHtml(document); + const framework = js ? detectFramework(js) : undefined; + const registeredEffects = js ? extractRegisteredEffects(js) : undefined; + + return { + config, + html, + css: css || undefined, + js: js || undefined, + framework, + registeredEffects, + sourceType: 'mixed', + }; +} + +// --------------------------------------------------------------------------- +// URL +// --------------------------------------------------------------------------- + +async function parseUrl(url: string): Promise { + const response = await fetch(url); + const source = await response.text(); + const artifact = parseMixed(source); + artifact.sourceType = 'url'; + return artifact; +} + +// --------------------------------------------------------------------------- +// Directory +// --------------------------------------------------------------------------- + +/** + * Shallow-merge two partial InteractConfig objects. + * Arrays (interactions) are concatenated; objects (effects, conditions, sequences) are merged. + */ +function mergeConfigs( + base: Partial, + incoming: Partial, +): Partial { + const merged: Record = { ...base }; + + for (const [key, value] of Object.entries(incoming)) { + const existing = (base as Record)[key]; + if (existing === undefined) { + merged[key] = value; + } else if (Array.isArray(existing) && Array.isArray(value)) { + merged[key] = [...existing, ...value]; + } else if ( + existing !== null && typeof existing === 'object' && !Array.isArray(existing) && + value !== null && typeof value === 'object' && !Array.isArray(value) + ) { + merged[key] = { ...existing, ...(value as Record) }; + } else { + merged[key] = value; + } + } + + return merged as Partial; +} + +function isLikelyConfig(obj: unknown): obj is Record { + return ( + obj !== null && + typeof obj === 'object' && + !Array.isArray(obj) && + ('interactions' in obj || 'effects' in obj) + ); +} + +async function parseDirectory(dirPath: string): Promise { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const entries = await fs.readdir(dirPath); + + const htmlParts: string[] = []; + const cssParts: string[] = []; + const jsParts: string[] = []; + let mergedConfig: Partial = {}; + let foundConfig = false; + + const htmlFiles: string[] = []; + const jsonFiles: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + const ext = path.extname(entry).toLowerCase(); + + if (ext === '.html' || ext === '.htm') { + htmlFiles.push(fullPath); + } else if (ext === '.json') { + jsonFiles.push(fullPath); + } else if (ext === '.css') { + const content = await readFileSafe(fs, fullPath); + if (content) cssParts.push(content); + } else if (ext === '.js' || ext === '.ts' || ext === '.tsx') { + const content = await readFileSafe(fs, fullPath); + if (content) jsParts.push(content); + } + } + + // Merge config from all JSON files that look like interact configs + for (const jsonFile of jsonFiles) { + const content = await readFileSafe(fs, jsonFile); + if (!content) continue; + try { + const parsed = JSON.parse(content); + if (isLikelyConfig(parsed)) { + mergedConfig = mergeConfigs(mergedConfig, parsed as Partial); + foundConfig = true; + } + } catch { + // not valid JSON, skip + } + } + + // Merge all HTML files; prioritize those with interact markers but include all + const interactHtmlFiles: string[] = []; + const otherHtmlFiles: string[] = []; + for (const htmlFile of htmlFiles) { + const content = await readFileSafe(fs, htmlFile); + if (!content) continue; + if (content.includes('data-interact-key') || content.includes('; + if (foundConfig) { + finalConfig = mergeConfigs(mixedArtifact.config as Partial, mergedConfig); + } else { + finalConfig = mixedArtifact.config; + } + + const allJs = finalJs || mixedArtifact.js; + return { + config: finalConfig as InteractConfig, + html: mixedArtifact.html, + css: finalCss || undefined, + js: finalJs || undefined, + framework: allJs ? detectFramework(allJs) : mixedArtifact.framework, + registeredEffects: allJs ? extractRegisteredEffects(allJs) : mixedArtifact.registeredEffects, + sourceType: 'directory', + }; + } + + // Try extracting config from JS if none found in JSON + if (!foundConfig && jsContent) { + const fromJs = extractConfigFromJs(jsContent); + if (fromJs) { + mergedConfig = mergeConfigs(mergedConfig, fromJs as Partial); + foundConfig = true; + } + } + + if (!foundConfig || !mergedConfig.interactions || !mergedConfig.effects) { + throw new Error( + 'Could not find InteractConfig in directory. Expected a .json file with { interactions, effects } or a .js file with Interact.create(...).', + ); + } + + const framework = jsContent ? detectFramework(jsContent) : undefined; + const registeredEffects = jsContent ? extractRegisteredEffects(jsContent) : undefined; + + return { + config: mergedConfig as InteractConfig, + html: htmlContent, + css: cssContent || undefined, + js: jsContent || undefined, + framework, + registeredEffects, + sourceType: 'directory', + }; +} + +/** Read a file, returning empty string on failure. */ +async function readFileSafe( + fs: typeof import('node:fs/promises'), + filePath: string, +): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return ''; + } +} + +/** Concatenate two optional content strings with a newline separator. */ +function joinParts(a: string | undefined, b: string | undefined): string | undefined { + if (a && b) return a + '\n' + b; + return a || b || undefined; +} + +// --------------------------------------------------------------------------- +// Extraction helpers +// --------------------------------------------------------------------------- + +function extractCss(document: Document): string { + const styles: string[] = []; + const styleTags = document.querySelectorAll('style'); + for (const tag of styleTags) { + if (tag.textContent) { + styles.push(tag.textContent); + } + tag.remove(); + } + return styles.join('\n'); +} + +function extractJs(document: Document): string { + const scripts: string[] = []; + const scriptTags = document.querySelectorAll('script'); + for (const tag of scriptTags) { + if (tag.src && (tag.src.includes('cdn') || tag.src.includes('unpkg') || tag.src.includes('jsdelivr'))) { + tag.remove(); + continue; + } + if (tag.type === 'application/json') { + // Leave in DOM for extractConfig to find, but don't collect as JS + continue; + } + if (tag.textContent) { + scripts.push(tag.textContent); + } + tag.remove(); + } + return scripts.join('\n'); +} + +function extractHtml(document: Document): string { + const jsonScripts = document.querySelectorAll('script[type="application/json"]'); + for (const tag of jsonScripts) { + tag.remove(); + } + + const body = document.body; + return body ? body.innerHTML.trim() : ''; +} + +/** + * Extract InteractConfig from JS source and/or DOM. + * Strategies in priority order: + * 1. Interact.create(...) call with inline object or variable reference + * 2. Variable with InteractConfig type annotation + * 3. + +`; + + const artifact = await parseArtifact({ type: 'mixed', source }); + + expect(artifact.sourceType).toBe('mixed'); + expect(artifact.config.interactions).toHaveLength(1); + expect(artifact.config.interactions[0].key).toBe('hero'); + expect(artifact.config.effects).toHaveProperty('fadeIn'); + expect(artifact.html).toContain('data-interact-key="hero"'); + expect(artifact.html).not.toContain(' tag', async () => { + const source = ` + + +
Hello
+ + +`; + + const artifact = await parseArtifact({ type: 'mixed', source }); + expect(artifact.config.interactions[0].key).toBe('hero'); + }); + + it('skips CDN script tags', async () => { + const source = ` + + +
Hello
+ + + +`; + + const artifact = await parseArtifact({ type: 'mixed', source }); + expect(artifact.js).toBeFalsy(); + }); + + it('detects react framework from JSX patterns', async () => { + const source = ` + + +
+ + +`; + + const artifact = await parseArtifact({ type: 'mixed', source }); + expect(artifact.framework).toBe('react'); + }); + }); + + describe('directory input', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'interact-debug-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('parses a directory with separated files', async () => { + await fs.writeFile(path.join(tmpDir, 'config.json'), JSON.stringify(MINIMAL_CONFIG)); + await fs.writeFile(path.join(tmpDir, 'index.html'), '
Hello
'); + await fs.writeFile(path.join(tmpDir, 'style.css'), '.hero { opacity: 0; }'); + await fs.writeFile(path.join(tmpDir, 'app.js'), 'import { Interact } from "@wix/interact";'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + + expect(artifact.sourceType).toBe('directory'); + expect(artifact.config.interactions).toHaveLength(1); + expect(artifact.html).toContain('data-interact-key="hero"'); + expect(artifact.css).toContain('.hero'); + expect(artifact.js).toContain('@wix/interact'); + }); + + it('merges configs from multiple JSON files (#1)', async () => { + const config1 = { + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } }, + interactions: [{ key: 'a', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }], + }; + const config2 = { + effects: { slideIn: { namedEffect: { type: 'SlideIn' }, duration: 300 } }, + interactions: [{ key: 'b', trigger: 'hover', effects: [{ effectId: 'slideIn' }] }], + }; + + await fs.writeFile(path.join(tmpDir, 'config1.json'), JSON.stringify(config1)); + await fs.writeFile(path.join(tmpDir, 'config2.json'), JSON.stringify(config2)); + await fs.writeFile(path.join(tmpDir, 'index.html'), '
A
'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + + expect(artifact.config.effects).toHaveProperty('fadeIn'); + expect(artifact.config.effects).toHaveProperty('slideIn'); + expect(artifact.config.interactions).toHaveLength(2); + }); + + it('merges HTML from multiple files (#2)', async () => { + await fs.writeFile(path.join(tmpDir, 'config.json'), JSON.stringify(MINIMAL_CONFIG)); + await fs.writeFile(path.join(tmpDir, 'hero.html'), '
Hero
'); + await fs.writeFile(path.join(tmpDir, 'banner.html'), '
Banner
'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + + expect(artifact.html).toContain('data-interact-key="hero"'); + expect(artifact.html).toContain('data-interact-key="banner"'); + }); + + it('merges JSON config with mixed-html-extracted config (#3)', async () => { + const jsonConfig = { + effects: { slideIn: { namedEffect: { type: 'SlideIn' }, duration: 300 } }, + interactions: [{ key: 'b', trigger: 'hover', effects: [{ effectId: 'slideIn' }] }], + }; + await fs.writeFile(path.join(tmpDir, 'extra.json'), JSON.stringify(jsonConfig)); + + const htmlContent = ` +
Hello
+ + `; + await fs.writeFile(path.join(tmpDir, 'index.html'), htmlContent); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + + expect(artifact.config.effects).toHaveProperty('fadeIn'); + expect(artifact.config.effects).toHaveProperty('slideIn'); + expect(artifact.config.interactions.length).toBeGreaterThanOrEqual(2); + }); + + it('sets sourceType to directory (#4)', async () => { + await fs.writeFile(path.join(tmpDir, 'config.json'), JSON.stringify(MINIMAL_CONFIG)); + await fs.writeFile(path.join(tmpDir, 'index.html'), '
Hello
'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + expect(artifact.sourceType).toBe('directory'); + }); + + it('sets sourceType to directory even with mixed HTML content (#4)', async () => { + const htmlContent = ` +
Hello
+ + `; + await fs.writeFile(path.join(tmpDir, 'index.html'), htmlContent); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + expect(artifact.sourceType).toBe('directory'); + }); + + it('merges CSS from multiple files (#5)', async () => { + await fs.writeFile(path.join(tmpDir, 'config.json'), JSON.stringify(MINIMAL_CONFIG)); + await fs.writeFile(path.join(tmpDir, 'index.html'), '
Hello
'); + await fs.writeFile(path.join(tmpDir, 'base.css'), '.base { color: red; }'); + await fs.writeFile(path.join(tmpDir, 'theme.css'), '.theme { color: blue; }'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + + expect(artifact.css).toContain('.base'); + expect(artifact.css).toContain('.theme'); + }); + + it('throws when no config is found in directory', async () => { + await fs.writeFile(path.join(tmpDir, 'index.html'), '
Hello
'); + await fs.writeFile(path.join(tmpDir, 'data.json'), '{"unrelated": true}'); + + await expect(parseArtifact({ type: 'directory', path: tmpDir })).rejects.toThrow( + /Could not find InteractConfig/, + ); + }); + + it('skips malformed JSON files gracefully (#7)', async () => { + await fs.writeFile(path.join(tmpDir, 'config.json'), JSON.stringify(MINIMAL_CONFIG)); + await fs.writeFile(path.join(tmpDir, 'broken.json'), '{invalid json!!!}'); + await fs.writeFile(path.join(tmpDir, 'index.html'), '
Hello
'); + + const artifact = await parseArtifact({ type: 'directory', path: tmpDir }); + expect(artifact.config.interactions).toHaveLength(1); + }); + }); +}); diff --git a/packages/interact-debug/test/compatibilityValidator.spec.ts b/packages/interact-debug/test/compatibilityValidator.spec.ts new file mode 100644 index 00000000..5af6956f --- /dev/null +++ b/packages/interact-debug/test/compatibilityValidator.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { validateCompatibility } from '../src/validate/compatibilityValidator'; +import type { InteractConfig } from '../src/types'; + +function makeConfig(interactions: any[], effects: Record = {}): InteractConfig { + return { effects, interactions }; +} + +describe('validateCompatibility', () => { + // ── Core trigger-effect pairing ──────────────────────────────────────── + + it('passes for time effect on time trigger', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewEnter', effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 500 }] }], + )); + expect(result.valid).toBe(true); + }); + + it('passes for scrub effect on scrub trigger', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewProgress', effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' } }] }], + )); + expect(result.valid).toBe(true); + }); + + it('passes for state effect on state trigger', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'color', value: 'red' }] } }] }], + )); + expect(result.valid).toBe(true); + }); + + it('errors for time effect on scrub trigger', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewProgress', effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 500 }] }], + )); + expect(result.errors.some((e) => e.rule === 'time-on-non-time-trigger')).toBe(true); + }); + + it('errors for scrub effect on time-only trigger', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewEnter', effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' } }] }], + )); + expect(result.errors.some((e) => e.rule === 'scrub-on-non-scrub-trigger')).toBe(true); + }); + + it('errors for state effect on non-state trigger (viewEnter)', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewEnter', effects: [{ transition: { styleProperties: [{ name: 'color', value: 'red' }] } }] }], + )); + expect(result.errors.some((e) => e.rule === 'state-on-non-state-trigger')).toBe(true); + }); + + it('errors for state effect on non-state trigger (viewProgress)', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewProgress', effects: [{ transition: { styleProperties: [{ name: 'opacity', value: '1' }] } }] }], + )); + expect(result.errors.some((e) => e.rule === 'state-on-non-state-trigger')).toBe(true); + }); + + it('accepts time effect on hover (also time-capable)', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500 }] }], + )); + expect(result.valid).toBe(true); + }); + + // ── triggerType / stateAction rules ───────────────────────────────────── + + it('warns on triggerType:state with non-state effect', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'hover', effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 500, triggerType: 'state' }] }], + )); + expect(result.warnings.some((w) => w.rule === 'triggerType-state-mismatch')).toBe(true); + }); + + it('errors when both triggerType and stateAction on same effect', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] }, triggerType: 'once', stateAction: 'toggle' }] }], + )); + expect(result.errors.some((e) => e.rule === 'triggerType-stateAction-mixed')).toBe(true); + }); + + // ── Sequence rules ───────────────────────────────────────────────────── + + it('warns on triggerType inside sequence effects', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewEnter', sequences: [{ effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 500, triggerType: 'once' }] }] }], + )); + expect(result.warnings.some((w) => w.rule === 'sequence-effect-triggerType')).toBe(true); + }); + + // ── animationEnd ──────────────────────────────────────────────────────── + + it('warns when animationEnd params.effectId references non-time effect', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'animationEnd', params: { effectId: 'stateEff' } }], + { stateEff: { transition: { styleProperties: [{ name: 'x', value: 'y' }] } } }, + )); + expect(result.warnings.some((w) => w.rule === 'animationEnd-non-time-effect')).toBe(true); + }); + + // ── effectId resolution ───────────────────────────────────────────────── + + it('resolves effectId through config.effects for compatibility check', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'a', trigger: 'viewProgress', effects: [{ effectId: 'scroll' }] }], + { scroll: { keyframeEffect: { name: 'x', keyframes: [{}] }, rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' } } }, + )); + expect(result.valid).toBe(true); + }); + + // ── Property affinity (moved from configValidator) ───────────────────── + + it('warns when triggerType appears on scrub effect', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'viewProgress', + effects: [{ + keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, + rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' }, + triggerType: 'once', + }], + }], + )); + expect(result.warnings.some((w) => w.rule === 'triggerType-affinity')).toBe(true); + }); + + it('warns when transitionEasing appears on non-scrub effect', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'hover', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500, transitionEasing: 'linear' }], + }], + )); + expect(result.warnings.some((w) => w.rule === 'transitionEasing-affinity')).toBe(true); + }); + + it('warns when stateAction appears on animation effect', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'hover', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500, stateAction: 'toggle' }], + }], + )); + expect(result.warnings.some((w) => w.rule === 'stateAction-affinity')).toBe(true); + }); + + it('warns when duration appears on scrub effect', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'viewProgress', + effects: [{ + keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, + rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' }, + duration: 500, + }], + }], + )); + expect(result.warnings.some((w) => w.rule === 'duration-on-scrub')).toBe(true); + }); + + // ── viewProgress range warnings (moved from configValidator) ────────── + + it('warns when viewProgress effect is missing rangeStart/rangeEnd', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'x', trigger: 'viewProgress', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] } }] }], + )); + expect(result.warnings.some((w) => w.rule === 'range-start-missing')).toBe(true); + expect(result.warnings.some((w) => w.rule === 'range-end-missing')).toBe(true); + }); + + // ── namedEffect scroll preset range warning (moved from configValidator) + + it('warns when scroll namedEffect in viewProgress has no range property', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeScroll' }, rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' } }], + }], + )); + expect(result.warnings.some((w) => w.rule === 'named-scroll-range')).toBe(true); + }); + + it('accepts scroll namedEffect with range property', () => { + const result = validateCompatibility(makeConfig( + [{ + key: 'x', trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeScroll', range: 'in' }, rangeStart: { name: 'entry' }, rangeEnd: { name: 'cover' } }], + }], + )); + expect(result.warnings.filter((w) => w.rule === 'named-scroll-range')).toHaveLength(0); + }); + + // ── duration-required for time effects (moved from configValidator) ──── + + it('errors when time effect is missing duration', () => { + const result = validateCompatibility(makeConfig( + [{ key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] } }] }], + )); + expect(result.errors.some((e) => e.rule === 'duration-required')).toBe(true); + }); +}); diff --git a/packages/interact-debug/test/configValidator.spec.ts b/packages/interact-debug/test/configValidator.spec.ts new file mode 100644 index 00000000..661ad20e --- /dev/null +++ b/packages/interact-debug/test/configValidator.spec.ts @@ -0,0 +1,622 @@ +import { describe, it, expect } from 'vitest'; +import { validateSchema } from '../src/validate/configValidator'; + +function validConfig(overrides?: Record) { + return { + effects: { + fadeIn: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 500, + }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ effectId: 'fadeIn' }], + }, + ], + ...overrides, + }; +} + +describe('validateSchema', () => { + // ── Happy path ────────────────────────────────────────────────────────── + + it('accepts a minimal valid config', () => { + const result = validateSchema(validConfig()); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts config with conditions', () => { + const result = validateSchema( + validConfig({ + conditions: { + desktop: { type: 'media', predicate: '(min-width: 1024px)' }, + }, + }), + ); + expect(result.valid).toBe(true); + }); + + it('accepts config with state effects on state-capable trigger', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'btn', + trigger: 'hover', + effects: [ + { + transition: { + duration: 200, + styleProperties: [{ name: 'background', value: 'red' }], + }, + }, + ], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('accepts config with scrub effects (rangeStart/rangeEnd, no duration)', () => { + const result = validateSchema({ + effects: { + scroll: { + keyframeEffect: { name: 'move', keyframes: [{ transform: 'translateY(0)' }, { transform: 'translateY(100px)' }] }, + rangeStart: { name: 'entry' }, + rangeEnd: { name: 'cover' }, + }, + }, + interactions: [ + { + key: 'panel', + trigger: 'viewProgress', + effects: [{ effectId: 'scroll' }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('accepts config with namedEffect', () => { + const result = validateSchema({ + effects: { + entrance: { + namedEffect: { type: 'FadeIn' }, + duration: 800, + }, + }, + interactions: [ + { + key: 'card', + trigger: 'viewEnter', + effects: [{ effectId: 'entrance' }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + // ── Top-level shape errors ────────────────────────────────────────────── + + it('rejects non-object config', () => { + const result = validateSchema('not an object'); + expect(result.valid).toBe(false); + expect(result.errors[0].rule).toBe('config-type'); + }); + + it('rejects config with array effects', () => { + const result = validateSchema({ + effects: [], + interactions: [{ key: 'a', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effects-not-array')).toBe(true); + }); + + it('rejects config with missing interactions', () => { + const result = validateSchema({ effects: {} }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'interactions-type')).toBe(true); + }); + + it('rejects config with empty interactions array', () => { + const result = validateSchema({ effects: {}, interactions: [] }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'interactions-empty')).toBe(true); + }); + + // ── Interaction-level errors ──────────────────────────────────────────── + + it('rejects interaction without key', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'interaction-key')).toBe(true); + }); + + it('rejects interaction with empty key', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: '', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'interaction-key')).toBe(true); + }); + + it('rejects interaction with invalid trigger', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'scroll', effects: [] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'interaction-trigger')).toBe(true); + }); + + it('warns when interaction has neither effects nor sequences (#3)', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'hover' }], + }); + expect(result.warnings.some((w) => w.rule === 'interaction-no-effects')).toBe(true); + }); + + // ── Effect resolution ────────────────────────────────────────────────── + + it('resolves EffectRef by merging with config.effects base', () => { + const result = validateSchema(validConfig()); + expect(result.valid).toBe(true); + }); + + it('allows EffectRef to override properties from base', () => { + const result = validateSchema({ + effects: { + base: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 500, + }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ effectId: 'base', duration: 800, fill: 'forwards' }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('flags ambiguity when EffectRef adds a different effectProperty than base', () => { + const result = validateSchema({ + effects: { + base: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 500, + }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ effectId: 'base', namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effect-property-exclusive')).toBe(true); + }); + + it('allows partial base in config.effects completed by inline override', () => { + const result = validateSchema({ + effects: { shared: { duration: 600, fill: 'forwards' } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ effectId: 'shared', keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] } }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('errors when resolved EffectRef still has no effect property', () => { + const result = validateSchema({ + effects: { empty: { duration: 500 } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'empty' }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effect-property')).toBe(true); + }); + + it('errors when effectId references non-existent effect and inline has no property', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effect-property')).toBe(true); + }); + + // ── Effect shape errors ───────────────────────────────────────────────── + + it('rejects effect with multiple animation properties', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'hover', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, namedEffect: { type: 'FadeIn' }, duration: 500 }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effect-property-exclusive')).toBe(true); + }); + + it('rejects effect mixing keyframe with transition', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'hover', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, transition: { styleProperties: [{ name: 'color', value: 'red' }] }, duration: 500 }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'effect-mixed-types')).toBe(true); + }); + + it('rejects effect with both transition and transitionProperties', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'hover', + effects: [{ transition: { styleProperties: [{ name: 'color', value: 'red' }] }, transitionProperties: [{ name: 'color', value: 'blue' }] }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'state-exclusive')).toBe(true); + }); + + it('rejects time effect with non-positive duration', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: -100 }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'duration-positive')).toBe(true); + }); + + it('rejects keyframeEffect with missing name', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { keyframes: [{ opacity: 0 }] }, duration: 500 }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'keyframe-name')).toBe(true); + }); + + it('rejects keyframeEffect with empty name (#7)', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: '', keyframes: [{ opacity: 0 }] }, duration: 500 }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'keyframe-name-empty')).toBe(true); + }); + + it('rejects keyframeEffect with empty keyframes', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'x', keyframes: [] }, duration: 500 }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'keyframe-keyframes')).toBe(true); + }); + + it('rejects namedEffect without type', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'hover', effects: [{ namedEffect: {}, duration: 500 }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'named-effect-type')).toBe(true); + }); + + // ── triggerType / fill / stateAction enums ────────────────────────────── + + it('rejects invalid triggerType value', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500, triggerType: 'loop' }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'trigger-type-value')).toBe(true); + }); + + it('rejects invalid fill value', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'x', trigger: 'hover', effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500, fill: 'auto' }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'fill-value')).toBe(true); + }); + + it('rejects invalid stateAction', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'btn', trigger: 'click', effects: [{ stateAction: 'flip', transition: { styleProperties: [{ name: 'color', value: 'blue' }] } }] }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'state-action-value')).toBe(true); + }); + + // ── rangeOffset validation ───────────────────────────────────────────── + + it('rejects invalid rangeStart name', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'viewProgress', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, rangeStart: { name: 'start' }, rangeEnd: { name: 'cover' } }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'range-name-value')).toBe(true); + }); + + it('validates rangeOffset offset object shape', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'viewProgress', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, rangeStart: { name: 'entry', offset: { value: 'bad', unit: 'percentage' } }, rangeEnd: { name: 'cover' } }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'range-offset-value')).toBe(true); + }); + + // ── Conditions definition shape ──────────────────────────────────────── + + it('rejects invalid condition type', () => { + const result = validateSchema(validConfig({ conditions: { bad: { type: 'viewport' } } })); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'condition-type-value')).toBe(true); + }); + + // ── Params validation (#2) ──────────────────────────────────────────── + + it('requires params for animationEnd even when undefined', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'animationEnd' }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'params-required')).toBe(true); + }); + + it('requires effectId in animationEnd params', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'animationEnd', params: {} }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'param-effect-id-required')).toBe(true); + }); + + it('rejects invalid hitArea in pointerMove params', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'pointerMove', + params: { hitArea: 'page' }, + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, rangeStart: { name: 'cover' }, rangeEnd: { name: 'cover' } }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'param-hit-area')).toBe(true); + }); + + it('rejects invalid axis in pointerMove params', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'x', + trigger: 'pointerMove', + params: { axis: 'z' }, + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, rangeStart: { name: 'cover' }, rangeEnd: { name: 'cover' } }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'param-axis')).toBe(true); + }); + + it('rejects invalid threshold type in viewEnter params', () => { + const result = validateSchema({ + effects: {}, + interactions: [{ key: 'x', trigger: 'viewEnter', params: { threshold: 'high' }, effects: [{ effectId: 'fadeIn' }] }], + }); + expect(result.errors.some((e) => e.rule === 'param-threshold')).toBe(true); + }); + + // ── Scope filtering ─────────────────────────────────────────────────── + + it('filters validation to a specific interaction index', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'a', trigger: 'badTrigger', effects: [] }, + { key: 'b', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }, + ], + }, { interactionIndex: 1 }); + expect(result.valid).toBe(true); + }); + + it('filters validation to a specific key', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'a', trigger: 'badTrigger', effects: [] }, + { key: 'b', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }, + ], + }, { key: 'b' }); + expect(result.valid).toBe(true); + }); + + it('filters validation to a specific trigger type', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'a', trigger: 'badTrigger', effects: [] }, + { key: 'b', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'x', value: 'y' }] } }] }, + ], + }, { trigger: 'hover' }); + expect(result.valid).toBe(true); + }); + + // ── Inline effects in interactions ───────────────────────────────────── + + it('validates inline effects within interactions', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] }, + ], + }); + expect(result.valid).toBe(true); + }); + + // ── Sequences ────────────────────────────────────────────────────────── + + it('validates inline sequence effects', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + sequences: [{ effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('resolves SequenceConfigRef from config.sequences', () => { + const result = validateSchema({ + effects: {}, + sequences: { entrance: { effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', sequences: [{ sequenceId: 'entrance' }] }], + }); + expect(result.valid).toBe(true); + }); + + it('allows sequenceId to inherit props while overriding effects (#4)', () => { + const result = validateSchema({ + effects: {}, + sequences: { + base: { + delay: 100, + effects: [{ keyframeEffect: { name: 'old', keyframes: [{ opacity: 0 }] }, duration: 300 }], + }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + sequences: [ + { + sequenceId: 'base', + effects: [{ keyframeEffect: { name: 'new', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }], + }, + ], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('validates effects inside sequences with resolution', () => { + const result = validateSchema({ + effects: { shared: { duration: 500 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + sequences: [{ effects: [{ effectId: 'shared', keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] } }] }], + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('validates sequence options', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + sequences: [{ delay: 'bad', effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'sequence-delay')).toBe(true); + }); + + it('validates conditions shape inside sequences', () => { + const result = validateSchema({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + sequences: [{ conditions: [123], effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] }], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.rule === 'condition-ref-type')).toBe(true); + }); +}); diff --git a/packages/interact-debug/test/integrationValidator.spec.ts b/packages/interact-debug/test/integrationValidator.spec.ts new file mode 100644 index 00000000..d8a9d35f --- /dev/null +++ b/packages/interact-debug/test/integrationValidator.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { validateIntegration } from '../src/validate/integrationValidator'; +import type { InteractArtifact, InteractConfig } from '../src/types'; + +function makeArtifact(overrides?: Partial): InteractArtifact { + return { + config: { + effects: { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 } as any, + }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] } as any, + ], + }, + html: '
Hello
', + js: 'import { Interact } from "@wix/interact";\nInteract.create(config);\nInteract.destroy();', + sourceType: 'separated', + ...overrides, + }; +} + +describe('validateIntegration', () => { + it('passes for a well-formed artifact', () => { + const result = validateIntegration(makeArtifact()); + expect(result.valid).toBe(true); + }); + + it('errors when config key has no matching HTML element', () => { + const result = validateIntegration(makeArtifact({ + html: '
No interact keys here
', + })); + expect(result.errors.some((e) => e.rule === 'key-missing-in-html')).toBe(true); + }); + + it('errors when registerEffects is missing but namedEffect is used', () => { + const artifact = makeArtifact({ + config: { + effects: { entrance: { namedEffect: { type: 'FadeIn' }, duration: 500 } as any }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] } as any], + }, + js: 'import { Interact } from "@wix/interact";\nInteract.create(config);\nInteract.destroy();', + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'register-effects-missing')).toBe(true); + }); + + it('errors when registerEffects is called after Interact.create', () => { + const artifact = makeArtifact({ + config: { + effects: { entrance: { namedEffect: { type: 'FadeIn' }, duration: 500 } as any }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] } as any], + }, + js: 'Interact.create(config);\nregisterEffects({ FadeIn });\nInteract.destroy();', + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'register-effects-order')).toBe(true); + }); + + it('warns when destroy is missing', () => { + const artifact = makeArtifact({ + js: 'import { Interact } from "@wix/interact";\nInteract.create(config);', + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'missing-destroy')).toBe(true); + }); + + it('errors when activate/interest trigger used without allowA11yTriggers', () => { + const artifact = makeArtifact({ + config: { + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } as any }, + interactions: [ + { key: 'hero', trigger: 'activate', effects: [{ effectId: 'fadeIn' }] } as any, + ], + }, + js: 'import { Interact } from "@wix/interact";\nInteract.create(config);\nInteract.destroy();', + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'missing-a11y-triggers')).toBe(true); + }); + + it('warns on click without activate', () => { + const artifact = makeArtifact({ + config: { + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } as any }, + interactions: [ + { key: 'btn', trigger: 'click', effects: [{ effectId: 'fadeIn' }] } as any, + ], + }, + html: '
Click me
', + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'click-without-activate')).toBe(true); + }); + + it('warns on hover without interest', () => { + const artifact = makeArtifact({ + config: { + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } as any }, + interactions: [ + { key: 'card', trigger: 'hover', effects: [{ effectId: 'fadeIn' }] } as any, + ], + }, + html: '
Hover me
', + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'hover-without-interest')).toBe(true); + }); + + it('warns on overflow:hidden in CSS for viewProgress', () => { + const artifact = makeArtifact({ + config: { + effects: { scroll: { keyframeEffect: { name: 'x', keyframes: [{}] }, rangeStart: {}, rangeEnd: {} } as any }, + interactions: [{ key: 'hero', trigger: 'viewProgress', effects: [{ effectId: 'scroll' }] } as any], + }, + css: '.container { overflow: hidden; }', + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'overflow-hidden')).toBe(true); + }); + + it('warns on pointer-events:none in CSS for pointerMove', () => { + const artifact = makeArtifact({ + config: { + effects: { mouse: { keyframeEffect: { name: 'x', keyframes: [{}] }, rangeStart: {}, rangeEnd: {} } as any }, + interactions: [{ key: 'hero', trigger: 'pointerMove', effects: [{ effectId: 'mouse' }] } as any], + }, + css: '.source { pointer-events: none; }', + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'pointer-events-none')).toBe(true); + }); + + it('errors when has no child', () => { + const artifact = makeArtifact({ + html: '', + framework: 'web', + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'interact-element-no-child')).toBe(true); + }); + + it('passes when has a child', () => { + const artifact = makeArtifact({ + html: '
Child
', + framework: 'web', + }); + const result = validateIntegration(artifact); + expect(result.errors.filter((e) => e.rule === 'interact-element-no-child')).toHaveLength(0); + }); + + it('errors on setup called after create', () => { + const artifact = makeArtifact({ + js: 'Interact.create(config);\nInteract.setup({});\nInteract.destroy();', + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'setup-order')).toBe(true); + }); +}); diff --git a/packages/interact-debug/test/logger.spec.ts b/packages/interact-debug/test/logger.spec.ts new file mode 100644 index 00000000..133a5ed5 --- /dev/null +++ b/packages/interact-debug/test/logger.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { InteractLogger } from '../src/log/logger'; + +describe('InteractLogger', () => { + it('logs entries and retrieves them', () => { + const logger = new InteractLogger(); + logger.info('config', 'test message'); + expect(logger.size).toBe(1); + const entries = logger.getLog(); + expect(entries).toHaveLength(1); + expect(entries[0].level).toBe('info'); + expect(entries[0].category).toBe('config'); + expect(entries[0].message).toBe('test message'); + expect(entries[0].timestamp).toBeGreaterThan(0); + }); + + it('supports all log levels', () => { + const logger = new InteractLogger(); + logger.debug('config', 'debug msg'); + logger.info('handler', 'info msg'); + logger.warn('lifecycle', 'warn msg'); + logger.error('dom', 'error msg'); + + expect(logger.size).toBe(4); + const levels = logger.getLog().map((e) => e.level); + expect(levels).toEqual(['debug', 'info', 'warn', 'error']); + }); + + it('supports context with key, trigger, effectId, data', () => { + const logger = new InteractLogger(); + logger.info('animation', 'animating', { key: 'hero', trigger: 'viewEnter', effectId: 'fadeIn', data: { progress: 0.5 } }); + + const entry = logger.getLog()[0]; + expect(entry.key).toBe('hero'); + expect(entry.trigger).toBe('viewEnter'); + expect(entry.effectId).toBe('fadeIn'); + expect(entry.data).toEqual({ progress: 0.5 }); + }); + + it('clears the log', () => { + const logger = new InteractLogger(); + logger.info('config', 'a'); + logger.info('config', 'b'); + expect(logger.size).toBe(2); + + logger.clearLog(); + expect(logger.size).toBe(0); + expect(logger.getLog()).toHaveLength(0); + }); + + it('filters by key', () => { + const logger = new InteractLogger(); + logger.info('config', 'hero stuff', { key: 'hero' }); + logger.info('config', 'banner stuff', { key: 'banner' }); + + const heroEntries = logger.getLogForKey('hero'); + expect(heroEntries).toHaveLength(1); + expect(heroEntries[0].message).toBe('hero stuff'); + }); + + it('filters by trigger', () => { + const logger = new InteractLogger(); + logger.info('handler', 'view enter', { trigger: 'viewEnter' }); + logger.info('handler', 'hover', { trigger: 'hover' }); + + const viewEntries = logger.getLogForTrigger('viewEnter'); + expect(viewEntries).toHaveLength(1); + expect(viewEntries[0].message).toBe('view enter'); + }); + + it('filters by category', () => { + const logger = new InteractLogger(); + logger.info('config', 'config msg'); + logger.info('handler', 'handler msg'); + logger.warn('config', 'config warn'); + + const configEntries = logger.getLogForCategory('config'); + expect(configEntries).toHaveLength(2); + }); + + it('filters by level', () => { + const logger = new InteractLogger(); + logger.debug('config', 'debug'); + logger.info('config', 'info'); + logger.warn('config', 'warn'); + logger.error('config', 'error'); + + const warnAndAbove = logger.getLogAtLevel('warn'); + expect(warnAndAbove).toHaveLength(2); + expect(warnAndAbove.map((e) => e.level)).toEqual(['warn', 'error']); + }); + + it('filters by custom predicate', () => { + const logger = new InteractLogger(); + logger.info('config', 'has data', { data: { x: 1 } }); + logger.info('config', 'no data'); + + const withData = logger.filterLog((e) => e.data !== undefined); + expect(withData).toHaveLength(1); + expect(withData[0].message).toBe('has data'); + }); + + it('respects minLevel option', () => { + const logger = new InteractLogger({ minLevel: 'warn' }); + logger.debug('config', 'debug'); + logger.info('config', 'info'); + logger.warn('config', 'warn'); + logger.error('config', 'error'); + + expect(logger.size).toBe(2); + expect(logger.getLog().map((e) => e.level)).toEqual(['warn', 'error']); + }); + + it('respects maxEntries option', () => { + const logger = new InteractLogger({ maxEntries: 3 }); + for (let i = 0; i < 10; i++) { + logger.info('config', `msg ${i}`); + } + + expect(logger.size).toBe(3); + expect(logger.getLog()[0].message).toBe('msg 7'); + expect(logger.getLog()[2].message).toBe('msg 9'); + }); + + it('does not log when disabled', () => { + const logger = new InteractLogger(); + logger.disable(); + logger.info('config', 'should not appear'); + expect(logger.size).toBe(0); + }); + + it('resumes logging when re-enabled', () => { + const logger = new InteractLogger(); + logger.disable(); + logger.info('config', 'invisible'); + logger.enable(); + logger.info('config', 'visible'); + expect(logger.size).toBe(1); + expect(logger.getLog()[0].message).toBe('visible'); + }); + + it('returns a copy from getLog (not the internal buffer)', () => { + const logger = new InteractLogger(); + logger.info('config', 'original'); + const log = logger.getLog(); + log.push({ timestamp: 0, level: 'debug', category: 'config', message: 'injected' }); + expect(logger.size).toBe(1); + }); +}); diff --git a/packages/interact-debug/test/referenceValidator.spec.ts b/packages/interact-debug/test/referenceValidator.spec.ts new file mode 100644 index 00000000..2791f565 --- /dev/null +++ b/packages/interact-debug/test/referenceValidator.spec.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; +import { validateReferences } from '../src/validate/referenceValidator'; +import type { InteractConfig } from '../src/types'; + +function makeConfig(overrides?: Partial): InteractConfig { + return { + effects: { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 } as any, + }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] } as any, + ], + ...overrides, + }; +} + +describe('validateReferences', () => { + it('passes when all references resolve', () => { + const result = validateReferences(makeConfig()); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('errors when effectId references non-existent effect', () => { + const result = validateReferences(makeConfig({ + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] } as any], + })); + expect(result.errors.some((e) => e.rule === 'effect-ref-missing')).toBe(true); + }); + + it('errors when condition reference is undefined', () => { + const config = makeConfig({ + conditions: { desktop: { type: 'media', predicate: '(min-width: 1024px)' } }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', conditions: ['missing'], effects: [{ effectId: 'fadeIn' }] } as any, + ], + }); + const result = validateReferences(config); + expect(result.errors.some((e) => e.rule === 'condition-ref-missing')).toBe(true); + }); + + it('errors when sequenceId references non-existent sequence', () => { + const config = makeConfig({ + sequences: {}, + interactions: [ + { key: 'hero', trigger: 'viewEnter', sequences: [{ sequenceId: 'missing' }] } as any, + ], + }); + const result = validateReferences(config); + expect(result.errors.some((e) => e.rule === 'sequence-ref-missing')).toBe(true); + }); + + it('warns on orphaned effect', () => { + const config = makeConfig({ + effects: { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } as any, + unused: { namedEffect: { type: 'FadeIn' }, duration: 300 } as any, + }, + }); + const result = validateReferences(config); + expect(result.warnings.some((w) => w.rule === 'orphan-effect' && w.message.includes('unused'))).toBe(true); + }); + + it('warns on orphaned condition', () => { + const config = makeConfig({ + conditions: { desktop: { type: 'media' }, unused: { type: 'selector' } }, + }); + const result = validateReferences(config); + expect(result.warnings.some((w) => w.rule === 'orphan-condition' && w.message.includes('unused'))).toBe(true); + }); + + it('warns on orphaned sequence', () => { + const config = makeConfig({ + sequences: { + entrance: { effects: [{ effectId: 'fadeIn' }] } as any, + }, + }); + const result = validateReferences(config); + expect(result.warnings.some((w) => w.rule === 'orphan-sequence')).toBe(true); + }); + + it('warns when cross-key effect targets non-existent key', () => { + const config = makeConfig({ + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn', key: 'other' }] } as any, + ], + }); + const result = validateReferences(config); + expect(result.warnings.some((w) => w.rule === 'cross-key-missing')).toBe(true); + }); + + it('does not warn on cross-key when target key exists', () => { + const config = makeConfig({ + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn', key: 'banner' }] } as any, + { key: 'banner', trigger: 'hover', effects: [] } as any, + ], + }); + const result = validateReferences(config); + expect(result.warnings.filter((w) => w.rule === 'cross-key-missing')).toHaveLength(0); + }); + + it('validates animationEnd params.effectId reference', () => { + const config = makeConfig({ + interactions: [ + { key: 'hero', trigger: 'animationEnd', params: { effectId: 'nonexistent' } } as any, + ], + }); + const result = validateReferences(config); + expect(result.errors.some((e) => e.rule === 'animationEnd-effect-ref')).toBe(true); + }); + + it('skips orphan detection when scope is provided', () => { + const config = makeConfig({ + effects: { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } as any, + unused: { namedEffect: { type: 'FadeIn' }, duration: 300 } as any, + }, + }); + const result = validateReferences(config, { key: 'hero' }); + expect(result.warnings.filter((w) => w.rule === 'orphan-effect')).toHaveLength(0); + }); + + // ── Condition refs moved from configValidator ─────────────────────────── + + it('errors when interaction references undefined condition', () => { + const result = validateReferences({ + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 } } as any, + conditions: { desktop: { type: 'media', predicate: '(min-width: 1024px)' } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', conditions: ['nonexistent'], effects: [{ effectId: 'fadeIn' }] } as any], + }); + expect(result.errors.some((e) => e.rule === 'condition-ref-missing')).toBe(true); + }); + + it('accepts interaction referencing a valid condition', () => { + const result = validateReferences({ + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 } } as any, + conditions: { desktop: { type: 'media', predicate: '(min-width: 1024px)' } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', conditions: ['desktop'], effects: [{ effectId: 'fadeIn' }] } as any], + }); + expect(result.valid).toBe(true); + }); + + it('errors when effect references undefined condition', () => { + const result = validateReferences({ + effects: {} as any, + conditions: {}, + interactions: [{ + key: 'hero', trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500, conditions: ['missing'] }], + } as any], + }); + expect(result.errors.some((e) => e.rule === 'condition-ref-missing')).toBe(true); + }); + + it('errors when sequence references non-existent sequence', () => { + const result = validateReferences({ + effects: {} as any, + sequences: {}, + interactions: [{ key: 'hero', trigger: 'viewEnter', sequences: [{ sequenceId: 'missing' }] } as any], + }); + expect(result.errors.some((e) => e.rule === 'sequence-ref-missing')).toBe(true); + }); + + it('errors when conditions inside sequences reference undefined condition', () => { + const result = validateReferences({ + effects: {} as any, + conditions: {}, + interactions: [{ + key: 'hero', trigger: 'viewEnter', + sequences: [{ conditions: ['missing'], effects: [{ keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }] }], + } as any], + }); + expect(result.errors.some((e) => e.rule === 'condition-ref-missing')).toBe(true); + }); +}); diff --git a/packages/interact-debug/test/registryValidator.spec.ts b/packages/interact-debug/test/registryValidator.spec.ts new file mode 100644 index 00000000..3b139cf5 --- /dev/null +++ b/packages/interact-debug/test/registryValidator.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { validateRegistry } from '../src/validate/registryValidator'; +import type { InteractArtifact } from '../src/types'; + +function makeArtifact(config: any, registeredEffects?: string[]): InteractArtifact { + return { + config, + html: '
Hello
', + sourceType: 'separated', + registeredEffects, + }; +} + +describe('validateRegistry', () => { + it('passes when namedEffect is a known preset and registered', () => { + const result = validateRegistry(makeArtifact( + { + effects: { entrance: { namedEffect: { type: 'FadeIn' }, duration: 500 } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] }], + }, + ['FadeIn'], + )); + expect(result.valid).toBe(true); + }); + + it('errors when namedEffect is unknown and not registered', () => { + const result = validateRegistry(makeArtifact( + { + effects: {}, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'CustomThing' }, duration: 500 }] }], + }, + [], + )); + expect(result.errors.some((e) => e.rule === 'unknown-named-effect')).toBe(true); + }); + + it('errors when namedEffect is a known preset but not registered', () => { + const result = validateRegistry(makeArtifact( + { + effects: { entrance: { namedEffect: { type: 'SlideIn' }, duration: 500 } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] }], + }, + [], + )); + expect(result.errors.some((e) => e.rule === 'preset-not-registered')).toBe(true); + }); + + it('passes when namedEffect is custom but registered', () => { + const result = validateRegistry(makeArtifact( + { + effects: {}, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'MyCustomEffect' }, duration: 500 }] }], + }, + ['MyCustomEffect'], + )); + expect(result.valid).toBe(true); + }); + + it('validates namedEffects inside sequences', () => { + const result = validateRegistry(makeArtifact( + { + effects: {}, + interactions: [ + { + key: 'hero', trigger: 'viewEnter', + sequences: [{ effects: [{ namedEffect: { type: 'BounceIn' }, duration: 500 }] }], + }, + ], + }, + [], + )); + expect(result.errors.some((e) => e.rule === 'preset-not-registered')).toBe(true); + }); + + it('resolves effectId to check namedEffect in base', () => { + const result = validateRegistry(makeArtifact( + { + effects: { eff: { namedEffect: { type: 'SpinIn' }, duration: 500 } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'eff' }] }], + }, + ['SpinIn'], + )); + expect(result.valid).toBe(true); + }); + + it('validates all preset categories', () => { + // Test one from each category + for (const type of ['FadeIn', 'Bounce', 'FadeScroll', 'TrackMouse', 'BgZoom']) { + const result = validateRegistry(makeArtifact( + { + effects: {}, + interactions: [{ key: 'a', trigger: 'viewEnter', effects: [{ namedEffect: { type }, duration: 500 }] }], + }, + [type], + )); + expect(result.valid).toBe(true); + } + }); +}); diff --git a/packages/interact-debug/tsconfig.build.json b/packages/interact-debug/tsconfig.build.json new file mode 100644 index 00000000..cd4981cd --- /dev/null +++ b/packages/interact-debug/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationDir": "dist/types", + "emitDeclarationOnly": true, + "noEmit": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"], + "references": [ + { + "path": "../motion" + }, + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-debug/tsconfig.json b/packages/interact-debug/tsconfig.json new file mode 100644 index 00000000..291c3e75 --- /dev/null +++ b/packages/interact-debug/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "declarationDir": "dist/types", + "declaration": true, + "composite": true, + "baseUrl": "." + }, + "include": ["src/**/*", "src"], + "references": [ + { + "path": "../motion" + }, + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-debug/vite.config.ts b/packages/interact-debug/vite.config.ts new file mode 100644 index 00000000..e05e898f --- /dev/null +++ b/packages/interact-debug/vite.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default defineConfig({ + build: { + lib: { + entry: { + index: path.resolve(__dirname, 'src/index.ts'), + playwright: path.resolve(__dirname, 'src/playwright/index.ts'), + cli: path.resolve(__dirname, 'src/cli.ts'), + }, + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: [ + '@wix/interact', + '@wix/motion', + '@wix/motion-presets', + '@playwright/test', + 'jsdom', + 'node:fs', + 'node:fs/promises', + 'node:path', + 'node:url', + 'vite', + ], + output: { + entryFileNames: '[format]/[name].js', + compact: true, + }, + }, + }, + resolve: { + alias: { + '@wix/interact': path.resolve(__dirname, '../interact/src/index.ts'), + '@wix/motion': path.resolve(__dirname, '../motion/src/index.ts'), + }, + }, +}); diff --git a/packages/interact-debug/vitest.config.ts b/packages/interact-debug/vitest.config.ts new file mode 100644 index 00000000..7e9bc2e4 --- /dev/null +++ b/packages/interact-debug/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: [], + }, +}); diff --git a/yarn.lock b/yarn.lock index 2d689cff..a4e9d61f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -993,6 +993,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7::__archiveUrl=https%3A%2F%2Fnpm.dev.wixpress.com%2Fapi%2Fnpm%2Fnpm-repos%2F%40types%2Fjsdom%2F-%2Fjsdom-21.1.7.tgz" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10/a5ee54aec813ac928ef783f69828213af4d81325f584e1fe7573a9ae139924c40768d1d5249237e62d51b9a34ed06bde059c86c6b0248d627457ec5e5d532dfa + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -1016,6 +1027,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*": + version: 25.5.2 + resolution: "@types/node@npm:25.5.2::__archiveUrl=https%3A%2F%2Fnpm.dev.wixpress.com%2Fapi%2Fnpm%2Fnpm-repos%2F%40types%2Fnode%2F-%2Fnode-25.5.2.tgz" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10/11782030f910ecf600cd537791980bd8b68496570ecd633d512d713b5b8a16ea3740fce85c82d0593305f809a7c205d7e86c07f179063fc98f014a7f9b013166 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.15 resolution: "@types/prop-types@npm:15.7.15" @@ -1042,6 +1062,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5::__archiveUrl=https%3A%2F%2Fnpm.dev.wixpress.com%2Fapi%2Fnpm%2Fnpm-repos%2F%40types%2Ftough-cookie%2F-%2Ftough-cookie-4.0.5.tgz" + checksum: 10/01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -1334,6 +1361,35 @@ __metadata: languageName: node linkType: hard +"@wix/interact-debug@workspace:packages/interact-debug": + version: 0.0.0-use.local + resolution: "@wix/interact-debug@workspace:packages/interact-debug" + dependencies: + "@types/jsdom": "npm:^21.1.7" + "@vitest/coverage-v8": "npm:^4.0.14" + "@wix/interact": "workspace:*" + "@wix/motion": "workspace:*" + "@wix/motion-presets": "workspace:*" + jsdom: "npm:^24.0.0" + rimraf: "npm:^6.0.1" + typescript: "npm:^5.9.3" + vite: "npm:^7.2.2" + vitest: "npm:^4.0.14" + peerDependencies: + "@playwright/test": ">=1.40.0" + "@wix/interact": ">=2.0.0" + "@wix/motion": ">=2.0.0" + "@wix/motion-presets": ">=1.0.0" + peerDependenciesMeta: + "@playwright/test": + optional: true + "@wix/motion-presets": + optional: true + bin: + interact-debug: ./dist/es/cli.js + languageName: unknown + linkType: soft + "@wix/interact-demo@workspace:apps/demo": version: 0.0.0-use.local resolution: "@wix/interact-demo@workspace:apps/demo" @@ -1368,7 +1424,7 @@ __metadata: languageName: unknown linkType: soft -"@wix/interact@npm:^2.2.0, @wix/interact@workspace:packages/interact": +"@wix/interact@npm:^2.2.0, @wix/interact@workspace:*, @wix/interact@workspace:packages/interact": version: 0.0.0-use.local resolution: "@wix/interact@workspace:packages/interact" dependencies: @@ -1400,7 +1456,7 @@ __metadata: languageName: unknown linkType: soft -"@wix/motion-presets@workspace:packages/motion-presets": +"@wix/motion-presets@workspace:*, @wix/motion-presets@workspace:packages/motion-presets": version: 0.0.0-use.local resolution: "@wix/motion-presets@workspace:packages/motion-presets" dependencies: @@ -1413,7 +1469,7 @@ __metadata: languageName: unknown linkType: soft -"@wix/motion@npm:^2.1.4, @wix/motion@workspace:packages/motion": +"@wix/motion@npm:^2.1.4, @wix/motion@workspace:*, @wix/motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "@wix/motion@workspace:packages/motion" dependencies: @@ -4724,6 +4780,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0": + version: 7.3.0 + resolution: "parse5@npm:7.3.0::__archiveUrl=https%3A%2F%2Fnpm.dev.wixpress.com%2Fapi%2Fnpm%2Fnpm-repos%2Fparse5%2F-%2Fparse5-7.3.0.tgz" + dependencies: + entities: "npm:^6.0.0" + checksum: 10/b0e48be20b820c655b138b86fa6fb3a790de6c891aa2aba536524f8027b4dca4fe538f11a0e5cf2f6f847d120dbb9e4822dcaeb933ff1e10850a2ef0154d1d88 + languageName: node + linkType: hard + "parse5@npm:^7.1.2": version: 7.3.0 resolution: "parse5@npm:7.3.0" @@ -5860,6 +5925,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2::__archiveUrl=https%3A%2F%2Fnpm.dev.wixpress.com%2Fapi%2Fnpm%2Fnpm-repos%2Fundici-types%2F-%2Fundici-types-7.18.2.tgz" + checksum: 10/e61a5918f624d68420c3ca9d301e9f15b61cba6e97be39fe2ce266dd6151e4afe424d679372638826cb506be33952774e0424141200111a9857e464216c009af + languageName: node + linkType: hard + "unified@npm:^11.0.0": version: 11.0.5 resolution: "unified@npm:11.0.5" From f83abb33e95fa889edd360deebb1d3485302f399 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 26 Apr 2026 15:01:14 +0300 Subject: [PATCH 2/5] second leg --- packages/interact-debug/src/index.ts | 47 +++ .../src/inspect/configInspector.ts | 300 ++++++++++++++++++ .../src/inspect/domInspector.ts | 203 ++++++++++++ packages/interact-debug/src/inspect/index.ts | 41 +++ .../src/inspect/runtimeValidator.ts | 239 ++++++++++++++ packages/interact-debug/src/log/index.ts | 2 + packages/interact-debug/src/log/patcher.ts | 150 +++++++++ .../test/configInspector.spec.ts | 166 ++++++++++ .../interact-debug/test/domInspector.spec.ts | 139 ++++++++ packages/interact-debug/test/patcher.spec.ts | 100 ++++++ .../test/runtimeValidator.spec.ts | 220 +++++++++++++ 11 files changed, 1607 insertions(+) create mode 100644 packages/interact-debug/src/inspect/configInspector.ts create mode 100644 packages/interact-debug/src/inspect/domInspector.ts create mode 100644 packages/interact-debug/src/inspect/index.ts create mode 100644 packages/interact-debug/src/inspect/runtimeValidator.ts create mode 100644 packages/interact-debug/src/log/patcher.ts create mode 100644 packages/interact-debug/test/configInspector.spec.ts create mode 100644 packages/interact-debug/test/domInspector.spec.ts create mode 100644 packages/interact-debug/test/patcher.spec.ts create mode 100644 packages/interact-debug/test/runtimeValidator.spec.ts diff --git a/packages/interact-debug/src/index.ts b/packages/interact-debug/src/index.ts index cfde5bc3..f5197b5c 100644 --- a/packages/interact-debug/src/index.ts +++ b/packages/interact-debug/src/index.ts @@ -39,4 +39,51 @@ export { validateEffect, } from './validate'; +// Logging export { InteractLogger } from './log/logger'; +export { enableLogging, disableLogging, getActiveLogger } from './log/patcher'; + +// Static inspection +export { + inspectConfig, + inspectInteraction, + inspectEffect, + inspectKey, +} from './inspect/configInspector'; + +export type { + ConfigSummary, + InteractionSummary, + ResolvedEffectSummary, + ResolvedSequenceSummary, + EffectUsageSummary, + KeySummary, +} from './inspect/configInspector'; + +// Runtime inspection (browser context) +export { + inspectElement, + getAnimationState, + inspectByKey, + findOrphanedElements, +} from './inspect/domInspector'; + +export type { + ElementInspection, + AnimationSnapshot, + AnimationState, +} from './inspect/domInspector'; + +// Runtime validation (browser context) +export { + validateRuntime, + validateKeyRuntime, + compareExpectedAnimations, + captureWarnings, + captureWarningsAsync, +} from './inspect/runtimeValidator'; + +export type { + RuntimeCheck, + CapturedWarning, +} from './inspect/runtimeValidator'; diff --git a/packages/interact-debug/src/inspect/configInspector.ts b/packages/interact-debug/src/inspect/configInspector.ts new file mode 100644 index 00000000..af1fb62a --- /dev/null +++ b/packages/interact-debug/src/inspect/configInspector.ts @@ -0,0 +1,300 @@ +import type { InteractConfig, TriggerType } from '../types'; +import { isRecord, resolveEffect, resolveSequence, buildGlobalMaps } from '../validate/helpers'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConfigSummary = { + interactionCount: number; + effectCount: number; + conditionCount: number; + sequenceCount: number; + uniqueKeys: string[]; + triggersUsed: TriggerType[]; + crossKeyEdges: { sourceKey: string; targetKey: string; effectId?: string }[]; + hasNamedEffects: boolean; + hasCustomEffects: boolean; + hasStateEffects: boolean; +}; + +export type InteractionSummary = { + index: number; + key: string; + trigger: TriggerType; + params?: Record; + conditions: string[]; + resolvedEffects: ResolvedEffectSummary[]; + resolvedSequences: ResolvedSequenceSummary[]; +}; + +export type ResolvedEffectSummary = { + effectId?: string; + kind: 'keyframe' | 'named' | 'custom' | 'state' | 'unknown'; + namedType?: string; + targetKey?: string; + properties: string[]; +}; + +export type ResolvedSequenceSummary = { + sequenceId?: string; + effectCount: number; + delay?: number; + offset?: number; + triggerType?: string; +}; + +export type EffectUsageSummary = { + effectId: string; + definition: Record; + kind: 'keyframe' | 'named' | 'custom' | 'state' | 'unknown'; + referencedBy: { interactionIndex: number; key: string; trigger: TriggerType; context: 'effect' | 'sequence' }[]; +}; + +export type KeySummary = { + key: string; + interactionsAsSource: { index: number; trigger: TriggerType }[]; + interactionsAsTarget: { index: number; sourceKey: string; trigger: TriggerType }[]; + effectIds: string[]; +}; + +// --------------------------------------------------------------------------- +// inspectConfig +// --------------------------------------------------------------------------- + +export function inspectConfig(config: InteractConfig): ConfigSummary { + const { globalEffects, globalSequences } = buildGlobalMaps(config); + const keys = new Set(); + const triggers = new Set(); + const crossKeyEdges: ConfigSummary['crossKeyEdges'] = []; + let hasNamed = false; + let hasCustom = false; + let hasState = false; + + for (const interaction of config.interactions) { + keys.add(interaction.key); + triggers.add(interaction.trigger); + + const effects = collectAllEffects(interaction, globalEffects, globalSequences); + + for (const eff of effects) { + if ('namedEffect' in eff) hasNamed = true; + if ('customEffect' in eff) hasCustom = true; + if ('transition' in eff || 'transitionProperties' in eff) hasState = true; + + if (typeof eff.key === 'string' && eff.key !== interaction.key) { + crossKeyEdges.push({ + sourceKey: interaction.key, + targetKey: eff.key as string, + effectId: typeof eff.effectId === 'string' ? (eff.effectId as string) : undefined, + }); + } + } + } + + return { + interactionCount: config.interactions.length, + effectCount: Object.keys(config.effects ?? {}).length, + conditionCount: Object.keys(config.conditions ?? {}).length, + sequenceCount: Object.keys(config.sequences ?? {}).length, + uniqueKeys: [...keys], + triggersUsed: [...triggers], + crossKeyEdges, + hasNamedEffects: hasNamed, + hasCustomEffects: hasCustom, + hasStateEffects: hasState, + }; +} + +// --------------------------------------------------------------------------- +// inspectInteraction +// --------------------------------------------------------------------------- + +export function inspectInteraction(config: InteractConfig, index: number): InteractionSummary | null { + const interaction = config.interactions[index]; + if (!interaction) return null; + + const { globalEffects, globalSequences } = buildGlobalMaps(config); + + const resolvedEffects: ResolvedEffectSummary[] = []; + if (interaction.effects) { + for (const raw of interaction.effects) { + const eff = isRecord(raw) ? resolveEffect(raw as Record, globalEffects) : (raw as Record); + resolvedEffects.push(summarizeEffect(eff)); + } + } + + const resolvedSequences: ResolvedSequenceSummary[] = []; + if (interaction.sequences) { + for (const raw of interaction.sequences) { + const seq = isRecord(raw) ? resolveSequence(raw as Record, globalSequences) : (raw as Record); + resolvedSequences.push({ + sequenceId: typeof (raw as Record).sequenceId === 'string' ? (raw as Record).sequenceId as string : undefined, + effectCount: Array.isArray(seq.effects) ? seq.effects.length : 0, + delay: typeof seq.delay === 'number' ? seq.delay as number : undefined, + offset: typeof seq.offset === 'number' ? seq.offset as number : undefined, + triggerType: typeof seq.triggerType === 'string' ? seq.triggerType as string : undefined, + }); + } + } + + return { + index, + key: interaction.key, + trigger: interaction.trigger, + params: isRecord(interaction.params) ? (interaction.params as Record) : undefined, + conditions: Array.isArray(interaction.conditions) ? (interaction.conditions as string[]) : [], + resolvedEffects, + resolvedSequences, + }; +} + +// --------------------------------------------------------------------------- +// inspectEffect +// --------------------------------------------------------------------------- + +export function inspectEffect(config: InteractConfig, effectId: string): EffectUsageSummary | null { + const definition = (config.effects as Record)?.[effectId]; + if (!isRecord(definition)) return null; + + const { globalEffects, globalSequences } = buildGlobalMaps(config); + const referencedBy: EffectUsageSummary['referencedBy'] = []; + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + + if (interaction.effects) { + for (const raw of interaction.effects) { + if (isRecord(raw) && (raw as Record).effectId === effectId) { + referencedBy.push({ interactionIndex: i, key: interaction.key, trigger: interaction.trigger, context: 'effect' }); + } + } + } + + if (interaction.sequences) { + for (const rawSeq of interaction.sequences) { + const seq = isRecord(rawSeq) ? resolveSequence(rawSeq as Record, globalSequences) : (rawSeq as Record); + if (Array.isArray(seq.effects)) { + for (const eff of seq.effects) { + if (isRecord(eff) && (eff as Record).effectId === effectId) { + referencedBy.push({ interactionIndex: i, key: interaction.key, trigger: interaction.trigger, context: 'sequence' }); + break; + } + } + } + } + } + } + + const resolved = resolveEffect({ effectId, ...definition } as Record, globalEffects); + + return { + effectId, + definition: definition as Record, + kind: classifyEffectKind(resolved), + referencedBy, + }; +} + +// --------------------------------------------------------------------------- +// inspectKey +// --------------------------------------------------------------------------- + +export function inspectKey(config: InteractConfig, key: string): KeySummary { + const { globalEffects, globalSequences } = buildGlobalMaps(config); + const asSource: KeySummary['interactionsAsSource'] = []; + const asTarget: KeySummary['interactionsAsTarget'] = []; + const effectIds = new Set(); + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + + if (interaction.key === key) { + asSource.push({ index: i, trigger: interaction.trigger }); + } + + const allEffects = collectAllEffects(interaction, globalEffects, globalSequences); + + for (const eff of allEffects) { + if (typeof eff.key === 'string' && eff.key === key && interaction.key !== key) { + asTarget.push({ index: i, sourceKey: interaction.key, trigger: interaction.trigger }); + } + if (interaction.key === key && typeof eff.effectId === 'string') { + effectIds.add(eff.effectId as string); + } + } + } + + return { + key, + interactionsAsSource: asSource, + interactionsAsTarget: asTarget, + effectIds: [...effectIds], + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function classifyEffectKind(eff: Record): ResolvedEffectSummary['kind'] { + if ('keyframeEffect' in eff) return 'keyframe'; + if ('namedEffect' in eff) return 'named'; + if ('customEffect' in eff) return 'custom'; + if ('transition' in eff || 'transitionProperties' in eff) return 'state'; + return 'unknown'; +} + +function summarizeEffect(eff: Record): ResolvedEffectSummary { + const kind = classifyEffectKind(eff); + const properties: string[] = []; + + if (kind === 'keyframe' && isRecord(eff.keyframeEffect)) { + const kf = eff.keyframeEffect as Record; + if (Array.isArray(kf.keyframes)) { + for (const frame of kf.keyframes) { + if (isRecord(frame)) { + properties.push(...Object.keys(frame as Record)); + } + } + } + } + + return { + effectId: typeof eff.effectId === 'string' ? (eff.effectId as string) : undefined, + kind, + namedType: isRecord(eff.namedEffect) && typeof (eff.namedEffect as Record).type === 'string' + ? (eff.namedEffect as Record).type as string + : undefined, + targetKey: typeof eff.key === 'string' ? (eff.key as string) : undefined, + properties: [...new Set(properties)], + }; +} + +function collectAllEffects( + interaction: Record, + globalEffects: Record>, + globalSequences: Record>, +): Record[] { + const result: Record[] = []; + + if (Array.isArray(interaction.effects)) { + for (const raw of interaction.effects) { + if (isRecord(raw)) result.push(resolveEffect(raw as Record, globalEffects)); + } + } + + if (Array.isArray(interaction.sequences)) { + for (const rawSeq of interaction.sequences) { + if (!isRecord(rawSeq)) continue; + const seq = resolveSequence(rawSeq as Record, globalSequences); + if (Array.isArray(seq.effects)) { + for (const eff of seq.effects) { + if (isRecord(eff)) result.push(resolveEffect(eff as Record, globalEffects)); + } + } + } + } + + return result; +} diff --git a/packages/interact-debug/src/inspect/domInspector.ts b/packages/interact-debug/src/inspect/domInspector.ts new file mode 100644 index 00000000..d7bc2070 --- /dev/null +++ b/packages/interact-debug/src/inspect/domInspector.ts @@ -0,0 +1,203 @@ +/** + * DOM inspector — runs in browser context. + * + * Provides introspection of data-interact-* attributes, adopted stylesheets, + * and Web Animations API state for Interact-managed elements. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ElementInspection = { + tagName: string; + key: string | null; + attributes: Record; + /** data-interact-* attribute values */ + interactAttributes: Record; + /** CSS rules from adopted stylesheets targeting this element (if any) */ + adoptedStyleRules: string[]; + animations: AnimationSnapshot[]; + childCount: number; +}; + +export type AnimationSnapshot = { + name: string; + playState: string; + currentTime: number | null; + duration: number | null; + progress: number | null; + keyframeCount: number; +}; + +export type AnimationState = { + name: string; + playState: string; + currentTime: number | null; + duration: number | null; + progress: number | null; +}; + +// --------------------------------------------------------------------------- +// inspectElement +// --------------------------------------------------------------------------- + +/** + * Inspect an element for Interact-related attributes and animation state. + */ +export function inspectElement(element: Element): ElementInspection { + const attrs: Record = {}; + const interactAttrs: Record = {}; + + for (const attr of element.attributes) { + attrs[attr.name] = attr.value; + if (attr.name.startsWith('data-interact')) { + interactAttrs[attr.name] = attr.value; + } + } + + const adoptedRules = getAdoptedStyleRules(element); + const animations = getAnimationSnapshots(element); + + return { + tagName: element.tagName.toLowerCase(), + key: element.getAttribute('data-interact-key'), + attributes: attrs, + interactAttributes: interactAttrs, + adoptedStyleRules: adoptedRules, + animations, + childCount: element.children.length, + }; +} + +// --------------------------------------------------------------------------- +// getAnimationState +// --------------------------------------------------------------------------- + +/** + * Get the state of all WAAPI animations on an element. + */ +export function getAnimationState(element: Element): AnimationState[] { + if (typeof (element as HTMLElement).getAnimations !== 'function') return []; + + return (element as HTMLElement).getAnimations().map((anim) => { + const effect = anim.effect as KeyframeEffect | null; + const timing = effect?.getComputedTiming?.(); + + return { + name: (anim as any).animationName ?? anim.id ?? '', + playState: anim.playState, + currentTime: typeof anim.currentTime === 'number' ? anim.currentTime : null, + duration: typeof timing?.duration === 'number' ? timing.duration : null, + progress: typeof timing?.progress === 'number' ? timing.progress : null, + }; + }); +} + +// --------------------------------------------------------------------------- +// inspectByKey +// --------------------------------------------------------------------------- + +/** + * Find an element by its data-interact-key and return a full inspection. + */ +export function inspectByKey(key: string, root?: ParentNode): ElementInspection | null { + const container = root ?? document; + const element = container.querySelector(`[data-interact-key="${key}"]`) + ?? container.querySelector(`interact-element[data-interact-key="${key}"]`); + + if (!element) return null; + return inspectElement(element); +} + +// --------------------------------------------------------------------------- +// findOrphanedElements +// --------------------------------------------------------------------------- + +/** + * Find all elements with data-interact-key that do NOT have a connected + * InteractionController (detected by the absence of data-interact-enter + * or data-interact-effect attributes, which are set by the library on connect). + * + * A more reliable check uses the Interact static cache when available. + */ +export function findOrphanedElements(root?: ParentNode): { key: string; element: Element }[] { + const container = root ?? document; + const allKeyed = container.querySelectorAll('[data-interact-key]'); + const orphans: { key: string; element: Element }[] = []; + + const Interact = (globalThis as any).Interact ?? (globalThis as any).window?.Interact; + const controllerCache: Map | undefined = Interact?.controllerCache; + + for (const element of allKeyed) { + const key = element.getAttribute('data-interact-key'); + if (!key) continue; + + if (controllerCache) { + if (!controllerCache.has(key)) { + orphans.push({ key, element }); + } + } else { + const hasController = element.hasAttribute('data-interact-enter') + || element.hasAttribute('data-interact-effect'); + if (!hasController) { + orphans.push({ key, element }); + } + } + } + + return orphans; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getAdoptedStyleRules(element: Element): string[] { + const rules: string[] = []; + try { + const doc = element.ownerDocument; + if (!doc?.adoptedStyleSheets) return rules; + + for (const sheet of doc.adoptedStyleSheets) { + for (const rule of sheet.cssRules) { + if (rule instanceof CSSStyleRule) { + try { + if (element.matches(rule.selectorText)) { + rules.push(rule.cssText); + } + } catch { + // invalid selector — skip + } + } + } + } + } catch { + // adoptedStyleSheets not supported + } + return rules; +} + +function getAnimationSnapshots(element: Element): AnimationSnapshot[] { + if (typeof (element as HTMLElement).getAnimations !== 'function') return []; + + return (element as HTMLElement).getAnimations().map((anim) => { + const effect = anim.effect as KeyframeEffect | null; + const timing = effect?.getComputedTiming?.(); + let keyframeCount = 0; + try { + keyframeCount = (effect as KeyframeEffect)?.getKeyframes?.()?.length ?? 0; + } catch { + // not all effects support getKeyframes + } + + return { + name: (anim as any).animationName ?? anim.id ?? '', + playState: anim.playState, + currentTime: typeof anim.currentTime === 'number' ? anim.currentTime : null, + duration: typeof timing?.duration === 'number' ? timing.duration : null, + progress: typeof timing?.progress === 'number' ? timing.progress : null, + keyframeCount, + }; + }); +} diff --git a/packages/interact-debug/src/inspect/index.ts b/packages/interact-debug/src/inspect/index.ts new file mode 100644 index 00000000..5a09cdaf --- /dev/null +++ b/packages/interact-debug/src/inspect/index.ts @@ -0,0 +1,41 @@ +export { + inspectConfig, + inspectInteraction, + inspectEffect, + inspectKey, +} from './configInspector'; + +export type { + ConfigSummary, + InteractionSummary, + ResolvedEffectSummary, + ResolvedSequenceSummary, + EffectUsageSummary, + KeySummary, +} from './configInspector'; + +export { + inspectElement, + getAnimationState, + inspectByKey, + findOrphanedElements, +} from './domInspector'; + +export type { + ElementInspection, + AnimationSnapshot, + AnimationState, +} from './domInspector'; + +export { + validateRuntime, + validateKeyRuntime, + compareExpectedAnimations, + captureWarnings, + captureWarningsAsync, +} from './runtimeValidator'; + +export type { + RuntimeCheck, + CapturedWarning, +} from './runtimeValidator'; diff --git a/packages/interact-debug/src/inspect/runtimeValidator.ts b/packages/interact-debug/src/inspect/runtimeValidator.ts new file mode 100644 index 00000000..91af0828 --- /dev/null +++ b/packages/interact-debug/src/inspect/runtimeValidator.ts @@ -0,0 +1,239 @@ +/** + * Runtime validator — runs in browser context. + * + * Validates that the live DOM is consistent with the InteractConfig: + * every config key has a DOM element, a connected controller, and the + * expected number of animations. + */ + +import type { InteractConfig } from '../types'; +import { isRecord, buildGlobalMaps, resolveEffect, resolveSequence } from '../validate/helpers'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type RuntimeCheck = { + key: string; + passed: boolean; + checks: { name: string; passed: boolean; expected?: string; actual?: string }[]; +}; + +export type CapturedWarning = { + timestamp: number; + message: string; + args: unknown[]; +}; + +// --------------------------------------------------------------------------- +// validateRuntime +// --------------------------------------------------------------------------- + +/** + * For every key in config, checks: + * - A matching DOM element exists + * - The element has a connected controller (via Interact.controllerCache or + * the presence of data-interact-enter/effect attrs) + * - The expected animation count matches actual WAAPI animations + */ +export function validateRuntime(config: InteractConfig, root?: ParentNode): RuntimeCheck[] { + const container = root ?? document; + const results: RuntimeCheck[] = []; + const seen = new Set(); + + for (const interaction of config.interactions) { + if (seen.has(interaction.key)) continue; + seen.add(interaction.key); + results.push(validateKeyRuntime(config, interaction.key, container)); + } + + return results; +} + +/** + * Validate a single key at runtime. + */ +export function validateKeyRuntime(config: InteractConfig, key: string, root?: ParentNode): RuntimeCheck { + const container = root ?? document; + const checks: RuntimeCheck['checks'] = []; + + // 1. DOM element exists + const el = container.querySelector(`[data-interact-key="${key}"]`) + ?? container.querySelector(`interact-element[data-interact-key="${key}"]`); + + const hasElement = el !== null; + checks.push({ name: 'dom-element-exists', passed: hasElement, expected: 'element present', actual: hasElement ? 'found' : 'missing' }); + + if (!el) { + return { key, passed: false, checks }; + } + + // 2. Controller connected + const controllerConnected = checkControllerConnected(el, key); + checks.push({ + name: 'controller-connected', + passed: controllerConnected, + expected: 'controller connected', + actual: controllerConnected ? 'connected' : 'not connected', + }); + + // 3. Animation count + const expected = countExpectedAnimations(config, key); + const actual = countActualAnimations(el); + // Only report if animations are expected and the trigger has likely fired + if (expected > 0) { + checks.push({ + name: 'animation-count', + passed: actual >= 0, // relaxed: animations may not have fired yet + expected: `${expected} expected`, + actual: `${actual} present`, + }); + } + + return { + key, + passed: checks.every((c) => c.passed), + checks, + }; +} + +// --------------------------------------------------------------------------- +// compareExpectedAnimations +// --------------------------------------------------------------------------- + +/** + * Count the expected number of animation effects for a key from the config, + * and compare with the actual WAAPI animation count on the element. + */ +export function compareExpectedAnimations( + config: InteractConfig, + key: string, + root?: ParentNode, +): { key: string; expected: number; actual: number; match: boolean } { + const container = root ?? document; + const expected = countExpectedAnimations(config, key); + + const el = container.querySelector(`[data-interact-key="${key}"]`); + const actual = el ? countActualAnimations(el) : 0; + + return { key, expected, actual, match: actual === expected }; +} + +// --------------------------------------------------------------------------- +// captureWarnings +// --------------------------------------------------------------------------- + +/** + * Run a callback while capturing all console.warn calls. + * Returns the captured warnings. + */ +export function captureWarnings(fn: () => void): CapturedWarning[] { + const captured: CapturedWarning[] = []; + const original = console.warn; + + console.warn = (...args: unknown[]) => { + captured.push({ + timestamp: Date.now(), + message: typeof args[0] === 'string' ? args[0] : String(args[0]), + args, + }); + }; + + try { + fn(); + } finally { + console.warn = original; + } + + return captured; +} + +/** + * Async variant of captureWarnings. + */ +export async function captureWarningsAsync(fn: () => Promise): Promise { + const captured: CapturedWarning[] = []; + const original = console.warn; + + console.warn = (...args: unknown[]) => { + captured.push({ + timestamp: Date.now(), + message: typeof args[0] === 'string' ? args[0] : String(args[0]), + args, + }); + }; + + try { + await fn(); + } finally { + console.warn = original; + } + + return captured; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function checkControllerConnected(el: Element, key: string): boolean { + const Interact = (globalThis as any).Interact ?? (globalThis as any).window?.Interact; + + if (Interact?.controllerCache) { + const controller = Interact.controllerCache.get(key); + return controller != null; + } + + // Fallback: check if the library has set its data attributes + return el.hasAttribute('data-interact-enter') + || el.hasAttribute('data-interact-effect') + || el.tagName.toLowerCase() === 'interact-element'; +} + +function countExpectedAnimations(config: InteractConfig, key: string): number { + const { globalEffects, globalSequences } = buildGlobalMaps(config); + let count = 0; + + for (const interaction of config.interactions) { + if (interaction.key !== key) continue; + + if (interaction.effects) { + for (const raw of interaction.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + // Only animation effects produce WAAPI animations (not state effects) + if ('keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff) { + // Only count if targeting same element (no cross-key) + if (!('key' in eff) || eff.key === key) { + count++; + } + } + } + } + + if (interaction.sequences) { + for (const rawSeq of interaction.sequences) { + if (!isRecord(rawSeq)) continue; + const seq = resolveSequence(rawSeq as Record, globalSequences); + if (Array.isArray(seq.effects)) { + for (const rawEff of seq.effects) { + if (!isRecord(rawEff)) continue; + const eff = resolveEffect(rawEff as Record, globalEffects); + if ('keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff) { + if (!('key' in eff) || eff.key === key) { + count++; + } + } + } + } + } + } + } + + return count; +} + +function countActualAnimations(el: Element): number { + if (typeof (el as HTMLElement).getAnimations !== 'function') return 0; + return (el as HTMLElement).getAnimations().length; +} diff --git a/packages/interact-debug/src/log/index.ts b/packages/interact-debug/src/log/index.ts index 7140b6dc..38e96a89 100644 --- a/packages/interact-debug/src/log/index.ts +++ b/packages/interact-debug/src/log/index.ts @@ -1,2 +1,4 @@ export { InteractLogger } from './logger'; export type { LoggerOptions } from './logger'; +export { enableLogging, disableLogging, getActiveLogger } from './patcher'; +export type { PatcherOptions } from './patcher'; diff --git a/packages/interact-debug/src/log/patcher.ts b/packages/interact-debug/src/log/patcher.ts new file mode 100644 index 00000000..f47c6b9b --- /dev/null +++ b/packages/interact-debug/src/log/patcher.ts @@ -0,0 +1,150 @@ +import { InteractLogger } from './logger'; + +export type PatcherOptions = { + /** Logger instance to write entries to. If not provided, a default is created. */ + logger?: InteractLogger; + /** Intercept console.warn calls that match Interact patterns. Default: true */ + interceptWarn?: boolean; + /** Log lifecycle events (create, destroy). Default: true */ + logLifecycle?: boolean; +}; + +type Cleanup = () => void; + +let activeCleanups: Cleanup[] = []; +let activeLogger: InteractLogger | null = null; + +/** + * Enable debug logging by patching global console.warn and (optionally) + * the Interact static API (create/destroy) to emit structured log entries. + * + * Call `disableLogging()` to undo all patches. + */ +export function enableLogging(options?: PatcherOptions): InteractLogger { + if (activeCleanups.length > 0) { + disableLogging(); + } + + const logger: InteractLogger = options?.logger ?? new InteractLogger(); + activeLogger = logger; + + const interceptWarn = options?.interceptWarn ?? true; + const logLifecycle = options?.logLifecycle ?? true; + + if (interceptWarn) { + activeCleanups.push(patchConsoleWarn(logger)); + } + + if (logLifecycle) { + activeCleanups.push(patchInteractLifecycle(logger)); + } + + return logger; +} + +/** + * Disable logging and restore all original functions. + */ +export function disableLogging(): void { + for (const cleanup of activeCleanups) { + cleanup(); + } + activeCleanups = []; + activeLogger = null; +} + +/** The currently active logger, or null if logging is disabled. */ +export function getActiveLogger(): InteractLogger | null { + return activeLogger; +} + +// --------------------------------------------------------------------------- +// console.warn interception +// --------------------------------------------------------------------------- + +const INTERACT_WARN_PATTERN = /^Interact:\s*/; +const KEY_EXTRACT = /key\s+"([^"]+)"/i; +const SEQUENCE_EXTRACT = /Sequence\s+"([^"]+)"/i; +const CONTROLLER_EXTRACT = /Controller\s+for\s+key\s+"([^"]+)"/i; +const INSTANCE_EXTRACT = /Instance\s+for\s+key\s+"([^"]+)"/i; + +function patchConsoleWarn(logger: InteractLogger): Cleanup { + const original = console.warn; + + console.warn = (...args: unknown[]) => { + const firstArg = args[0]; + + if (typeof firstArg === 'string' && INTERACT_WARN_PATTERN.test(firstArg)) { + const message = firstArg.replace(INTERACT_WARN_PATTERN, ''); + const key = extractKey(firstArg); + const category = categorizeWarnMessage(firstArg); + + logger.warn(category, message, { key, data: args.length > 1 ? args.slice(1) : undefined }); + } + + original.apply(console, args); + }; + + return () => { + console.warn = original; + }; +} + +function extractKey(msg: string): string | undefined { + return KEY_EXTRACT.exec(msg)?.[1] + ?? CONTROLLER_EXTRACT.exec(msg)?.[1] + ?? INSTANCE_EXTRACT.exec(msg)?.[1] + ?? undefined; +} + +function categorizeWarnMessage(msg: string): 'handler' | 'lifecycle' | 'dom' | 'config' | 'sequence' { + if (SEQUENCE_EXTRACT.test(msg)) return 'sequence'; + if (/controller/i.test(msg)) return 'dom'; + if (/instance/i.test(msg)) return 'lifecycle'; + if (/container|selector|element/i.test(msg)) return 'dom'; + return 'config'; +} + +// --------------------------------------------------------------------------- +// Interact lifecycle patching +// --------------------------------------------------------------------------- + +function patchInteractLifecycle(logger: InteractLogger): Cleanup { + let InteractClass: any; + try { + InteractClass = require('@wix/interact').Interact; + } catch { + return () => {}; + } + + const originalCreate = InteractClass.create; + const originalDestroy = InteractClass.destroy; + const originalInstanceDestroy = InteractClass.prototype?.destroy; + + if (typeof originalCreate === 'function') { + InteractClass.create = function patchedCreate(...args: unknown[]) { + logger.info('lifecycle', 'Interact.create() called', { data: { interactionCount: (args[0] as any)?.interactions?.length } }); + return originalCreate.apply(this, args); + }; + } + + if (typeof originalDestroy === 'function') { + InteractClass.destroy = function patchedDestroy(...args: unknown[]) { + logger.info('lifecycle', 'Interact.destroy() called (static)'); + return originalDestroy.apply(this, args); + }; + } + + if (typeof originalInstanceDestroy === 'function') { + InteractClass.prototype.destroy = function patchedInstanceDestroy(...args: unknown[]) { + logger.info('lifecycle', 'instance.destroy() called'); + return originalInstanceDestroy.apply(this, args); + }; + } + + return () => { + if (typeof originalCreate === 'function') InteractClass.create = originalCreate; + if (typeof originalDestroy === 'function') InteractClass.destroy = originalDestroy; + if (typeof originalInstanceDestroy === 'function') InteractClass.prototype.destroy = originalInstanceDestroy; + }; +} diff --git a/packages/interact-debug/test/configInspector.spec.ts b/packages/interact-debug/test/configInspector.spec.ts new file mode 100644 index 00000000..082b388a --- /dev/null +++ b/packages/interact-debug/test/configInspector.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { inspectConfig, inspectInteraction, inspectEffect, inspectKey } from '../src/inspect/configInspector'; +import type { InteractConfig } from '../src/types'; + +function makeConfig(overrides?: Partial): InteractConfig { + return { + effects: { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, duration: 500 }, + grow: { namedEffect: { type: 'GrowScroll' } }, + } as any, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + { key: 'panel', trigger: 'viewProgress', effects: [{ effectId: 'grow' }] }, + ] as any, + ...overrides, + }; +} + +describe('inspectConfig', () => { + it('returns correct counts and unique keys', () => { + const summary = inspectConfig(makeConfig()); + expect(summary.interactionCount).toBe(2); + expect(summary.effectCount).toBe(2); + expect(summary.uniqueKeys).toContain('hero'); + expect(summary.uniqueKeys).toContain('panel'); + expect(summary.triggersUsed).toContain('viewEnter'); + expect(summary.triggersUsed).toContain('viewProgress'); + }); + + it('detects named effects', () => { + const summary = inspectConfig(makeConfig()); + expect(summary.hasNamedEffects).toBe(true); + }); + + it('detects state effects', () => { + const summary = inspectConfig(makeConfig({ + effects: {} as any, + interactions: [ + { key: 'btn', trigger: 'hover', effects: [{ transition: { styleProperties: [{ name: 'color', value: 'red' }] } }] }, + ] as any, + })); + expect(summary.hasStateEffects).toBe(true); + }); + + it('detects cross-key edges', () => { + const summary = inspectConfig(makeConfig({ + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } } as any, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn', key: 'banner' }] }, + ] as any, + })); + expect(summary.crossKeyEdges).toHaveLength(1); + expect(summary.crossKeyEdges[0]).toEqual({ sourceKey: 'hero', targetKey: 'banner', effectId: 'fadeIn' }); + }); + + it('reports conditions and sequences counts', () => { + const summary = inspectConfig(makeConfig({ + conditions: { desktop: { type: 'media' } }, + sequences: { entrance: { effects: [] } }, + } as any)); + expect(summary.conditionCount).toBe(1); + expect(summary.sequenceCount).toBe(1); + }); +}); + +describe('inspectInteraction', () => { + it('returns null for out-of-bounds index', () => { + expect(inspectInteraction(makeConfig(), 99)).toBeNull(); + }); + + it('returns resolved effects for interaction', () => { + const result = inspectInteraction(makeConfig(), 0); + expect(result).not.toBeNull(); + expect(result!.key).toBe('hero'); + expect(result!.trigger).toBe('viewEnter'); + expect(result!.resolvedEffects).toHaveLength(1); + expect(result!.resolvedEffects[0].kind).toBe('keyframe'); + expect(result!.resolvedEffects[0].effectId).toBe('fadeIn'); + expect(result!.resolvedEffects[0].properties).toContain('opacity'); + }); + + it('resolves sequences', () => { + const config = makeConfig({ + sequences: { seq: { effects: [{ effectId: 'fadeIn' }], delay: 100 } } as any, + interactions: [ + { key: 'hero', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }, + ] as any, + }); + const result = inspectInteraction(config, 0); + expect(result!.resolvedSequences).toHaveLength(1); + expect(result!.resolvedSequences[0].sequenceId).toBe('seq'); + expect(result!.resolvedSequences[0].delay).toBe(100); + }); + + it('includes conditions', () => { + const config = makeConfig({ + conditions: { desktop: { type: 'media' } }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', conditions: ['desktop'], effects: [{ effectId: 'fadeIn' }] }, + ] as any, + }); + const result = inspectInteraction(config, 0); + expect(result!.conditions).toEqual(['desktop']); + }); +}); + +describe('inspectEffect', () => { + it('returns null for unknown effect', () => { + expect(inspectEffect(makeConfig(), 'nonexistent')).toBeNull(); + }); + + it('returns usage and kind for a known effect', () => { + const result = inspectEffect(makeConfig(), 'fadeIn'); + expect(result).not.toBeNull(); + expect(result!.effectId).toBe('fadeIn'); + expect(result!.kind).toBe('keyframe'); + expect(result!.referencedBy).toHaveLength(1); + expect(result!.referencedBy[0].key).toBe('hero'); + expect(result!.referencedBy[0].context).toBe('effect'); + }); + + it('detects usage within sequences', () => { + const config = makeConfig({ + sequences: { seq: { effects: [{ effectId: 'fadeIn' }] } } as any, + interactions: [ + { key: 'hero', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }, + ] as any, + }); + const result = inspectEffect(config, 'fadeIn'); + expect(result!.referencedBy).toHaveLength(1); + expect(result!.referencedBy[0].context).toBe('sequence'); + }); +}); + +describe('inspectKey', () => { + it('finds interactions where key is source', () => { + const result = inspectKey(makeConfig(), 'hero'); + expect(result.key).toBe('hero'); + expect(result.interactionsAsSource).toHaveLength(1); + expect(result.interactionsAsSource[0].trigger).toBe('viewEnter'); + }); + + it('finds interactions where key is target (cross-key)', () => { + const config = makeConfig({ + effects: { fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 } } as any, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn', key: 'banner' }] }, + ] as any, + }); + const result = inspectKey(config, 'banner'); + expect(result.interactionsAsTarget).toHaveLength(1); + expect(result.interactionsAsTarget[0].sourceKey).toBe('hero'); + }); + + it('collects effectIds used for the key', () => { + const result = inspectKey(makeConfig(), 'hero'); + expect(result.effectIds).toContain('fadeIn'); + }); + + it('returns empty arrays for unknown key', () => { + const result = inspectKey(makeConfig(), 'nonexistent'); + expect(result.interactionsAsSource).toHaveLength(0); + expect(result.interactionsAsTarget).toHaveLength(0); + expect(result.effectIds).toHaveLength(0); + }); +}); diff --git a/packages/interact-debug/test/domInspector.spec.ts b/packages/interact-debug/test/domInspector.spec.ts new file mode 100644 index 00000000..03b26ae1 --- /dev/null +++ b/packages/interact-debug/test/domInspector.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { inspectElement, getAnimationState, inspectByKey, findOrphanedElements } from '../src/inspect/domInspector'; + +function addKeyedElement(key: string, attrs: Record = {}): HTMLElement { + const el = document.createElement('div'); + el.setAttribute('data-interact-key', key); + for (const [name, value] of Object.entries(attrs)) { + el.setAttribute(name, value); + } + document.body.appendChild(el); + return el; +} + +describe('domInspector', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('inspectElement', () => { + it('returns tag name and key', () => { + const el = addKeyedElement('hero'); + const result = inspectElement(el); + expect(result.tagName).toBe('div'); + expect(result.key).toBe('hero'); + }); + + it('extracts all attributes', () => { + const el = addKeyedElement('hero', { id: 'test', class: 'foo' }); + const result = inspectElement(el); + expect(result.attributes['id']).toBe('test'); + expect(result.attributes['class']).toBe('foo'); + }); + + it('extracts data-interact-* attributes separately', () => { + const el = addKeyedElement('hero', { 'data-interact-initial': 'true', 'data-interact-enter': 'fade' }); + const result = inspectElement(el); + expect(result.interactAttributes['data-interact-key']).toBe('hero'); + expect(result.interactAttributes['data-interact-initial']).toBe('true'); + expect(result.interactAttributes['data-interact-enter']).toBe('fade'); + }); + + it('reports child count', () => { + const el = addKeyedElement('hero'); + el.innerHTML = 'ab'; + const result = inspectElement(el); + expect(result.childCount).toBe(2); + }); + + it('returns empty animations in jsdom', () => { + const el = addKeyedElement('hero'); + const result = inspectElement(el); + expect(result.animations).toEqual([]); + }); + }); + + describe('getAnimationState', () => { + it('returns empty array in jsdom (no WAAPI)', () => { + const el = addKeyedElement('hero'); + const result = getAnimationState(el); + expect(result).toEqual([]); + }); + }); + + describe('inspectByKey', () => { + it('finds element by data-interact-key', () => { + addKeyedElement('hero'); + const result = inspectByKey('hero'); + expect(result).not.toBeNull(); + expect(result!.key).toBe('hero'); + }); + + it('returns null for unknown key', () => { + const result = inspectByKey('nonexistent'); + expect(result).toBeNull(); + }); + + it('accepts a custom root', () => { + const container = document.createElement('div'); + const el = document.createElement('div'); + el.setAttribute('data-interact-key', 'scoped'); + container.appendChild(el); + document.body.appendChild(container); + + const result = inspectByKey('scoped', container); + expect(result).not.toBeNull(); + expect(result!.key).toBe('scoped'); + }); + }); + + describe('findOrphanedElements', () => { + it('finds elements without controller attributes', () => { + addKeyedElement('hero'); + addKeyedElement('panel'); + + const orphans = findOrphanedElements(); + expect(orphans).toHaveLength(2); + expect(orphans.map((o) => o.key)).toContain('hero'); + expect(orphans.map((o) => o.key)).toContain('panel'); + }); + + it('does not count elements with data-interact-enter', () => { + addKeyedElement('hero', { 'data-interact-enter': 'fade' }); + addKeyedElement('panel'); + + const orphans = findOrphanedElements(); + expect(orphans).toHaveLength(1); + expect(orphans[0].key).toBe('panel'); + }); + + it('does not count elements with data-interact-effect', () => { + addKeyedElement('hero', { 'data-interact-effect': 'active' }); + const orphans = findOrphanedElements(); + expect(orphans).toHaveLength(0); + }); + + it('returns empty when no keyed elements exist', () => { + const orphans = findOrphanedElements(); + expect(orphans).toHaveLength(0); + }); + + it('scopes to a custom root', () => { + addKeyedElement('outside'); + + const container = document.createElement('div'); + const el = document.createElement('div'); + el.setAttribute('data-interact-key', 'inside'); + container.appendChild(el); + document.body.appendChild(container); + + const orphans = findOrphanedElements(container); + expect(orphans).toHaveLength(1); + expect(orphans[0].key).toBe('inside'); + }); + }); +}); diff --git a/packages/interact-debug/test/patcher.spec.ts b/packages/interact-debug/test/patcher.spec.ts new file mode 100644 index 00000000..4a649e32 --- /dev/null +++ b/packages/interact-debug/test/patcher.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { enableLogging, disableLogging, getActiveLogger } from '../src/log/patcher'; +import { InteractLogger } from '../src/log/logger'; + +describe('patcher', () => { + afterEach(() => { + disableLogging(); + }); + + it('returns a logger when enabling', () => { + const logger = enableLogging(); + expect(logger).toBeInstanceOf(InteractLogger); + }); + + it('accepts a custom logger', () => { + const custom = new InteractLogger(); + const returned = enableLogging({ logger: custom }); + expect(returned).toBe(custom); + }); + + it('getActiveLogger returns the active logger', () => { + expect(getActiveLogger()).toBeNull(); + const logger = enableLogging(); + expect(getActiveLogger()).toBe(logger); + }); + + it('getActiveLogger returns null after disabling', () => { + enableLogging(); + disableLogging(); + expect(getActiveLogger()).toBeNull(); + }); + + describe('console.warn interception', () => { + it('captures Interact console.warn calls', () => { + const logger = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Interact: No container found for list container "myList"'); + + const log = logger.getLog(); + expect(log).toHaveLength(1); + expect(log[0].level).toBe('warn'); + expect(log[0].message).toBe('No container found for list container "myList"'); + }); + + it('extracts key from Interact warnings', () => { + const logger = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Interact: Instance for key "hero" not found'); + + const log = logger.getLog(); + expect(log[0].key).toBe('hero'); + }); + + it('categorizes controller warnings as dom', () => { + const logger = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Interact: Controller for key "hero" not found'); + + const log = logger.getLog(); + expect(log[0].category).toBe('dom'); + }); + + it('categorizes sequence warnings as sequence', () => { + const logger = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Interact: Sequence "entrance" not found in config'); + + const log = logger.getLog(); + expect(log[0].category).toBe('sequence'); + }); + + it('ignores non-Interact console.warn calls', () => { + const logger = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Some other warning'); + + const log = logger.getLog(); + expect(log).toHaveLength(0); + }); + + it('restores original console.warn on disable', () => { + const original = console.warn; + enableLogging({ interceptWarn: true, logLifecycle: false }); + expect(console.warn).not.toBe(original); + + disableLogging(); + expect(console.warn).toBe(original); + }); + }); + + it('re-enabling replaces previous patches', () => { + const logger1 = enableLogging({ interceptWarn: true, logLifecycle: false }); + const logger2 = enableLogging({ interceptWarn: true, logLifecycle: false }); + + console.warn('Interact: Instance for key "test" not found'); + + expect(logger1.getLog()).toHaveLength(0); + expect(logger2.getLog()).toHaveLength(1); + }); +}); diff --git a/packages/interact-debug/test/runtimeValidator.spec.ts b/packages/interact-debug/test/runtimeValidator.spec.ts new file mode 100644 index 00000000..0b8b92a7 --- /dev/null +++ b/packages/interact-debug/test/runtimeValidator.spec.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + validateRuntime, + validateKeyRuntime, + compareExpectedAnimations, + captureWarnings, + captureWarningsAsync, +} from '../src/inspect/runtimeValidator'; +import type { InteractConfig } from '../src/types'; + +function makeConfig(interactions: any[], effects: Record = {}): InteractConfig { + return { effects, interactions }; +} + +function addKeyedElement(key: string, tag = 'div'): HTMLElement { + const el = document.createElement(tag); + el.setAttribute('data-interact-key', key); + document.body.appendChild(el); + return el; +} + +describe('runtimeValidator', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('validateRuntime', () => { + it('passes when all keys have DOM elements', () => { + addKeyedElement('hero'); + addKeyedElement('panel'); + const config = makeConfig([ + { key: 'hero', trigger: 'viewEnter', effects: [] }, + { key: 'panel', trigger: 'hover', effects: [] }, + ]); + + const results = validateRuntime(config); + expect(results).toHaveLength(2); + expect(results.every((r) => r.checks.find((c) => c.name === 'dom-element-exists')?.passed)).toBe(true); + }); + + it('fails when a key has no DOM element', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { key: 'hero', trigger: 'viewEnter', effects: [] }, + { key: 'missing', trigger: 'hover', effects: [] }, + ]); + + const results = validateRuntime(config); + const missingResult = results.find((r) => r.key === 'missing'); + expect(missingResult?.passed).toBe(false); + expect(missingResult?.checks.find((c) => c.name === 'dom-element-exists')?.passed).toBe(false); + }); + + it('deduplicates keys (only checks once per key)', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { key: 'hero', trigger: 'viewEnter', effects: [] }, + { key: 'hero', trigger: 'hover', effects: [] }, + ]); + + const results = validateRuntime(config); + expect(results).toHaveLength(1); + }); + }); + + describe('validateKeyRuntime', () => { + it('returns checks for a specific key', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + ], { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 }, + }); + + const result = validateKeyRuntime(config, 'hero'); + expect(result.key).toBe('hero'); + expect(result.checks.find((c) => c.name === 'dom-element-exists')?.passed).toBe(true); + }); + + it('fails all checks when element is missing', () => { + const config = makeConfig([ + { key: 'hero', trigger: 'viewEnter', effects: [] }, + ]); + + const result = validateKeyRuntime(config, 'hero'); + expect(result.passed).toBe(false); + expect(result.checks).toHaveLength(1); + expect(result.checks[0].name).toBe('dom-element-exists'); + }); + }); + + describe('compareExpectedAnimations', () => { + it('counts expected animation effects for a key', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { + key: 'hero', trigger: 'viewEnter', + effects: [ + { effectId: 'fadeIn' }, + { effectId: 'slideIn' }, + ], + }, + ], { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 }, + slideIn: { keyframeEffect: { name: 'slide', keyframes: [{ transform: 'translateX(0)' }] }, duration: 500 }, + }); + + const result = compareExpectedAnimations(config, 'hero'); + expect(result.expected).toBe(2); + expect(result.actual).toBe(0); // no actual animations in jsdom + }); + + it('excludes state effects from expected count', () => { + addKeyedElement('btn'); + const config = makeConfig([ + { + key: 'btn', trigger: 'hover', + effects: [ + { transition: { styleProperties: [{ name: 'color', value: 'red' }] } }, + ], + }, + ]); + + const result = compareExpectedAnimations(config, 'btn'); + expect(result.expected).toBe(0); + }); + + it('excludes cross-key effects from expected count', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { + key: 'hero', trigger: 'viewEnter', + effects: [ + { effectId: 'fadeIn', key: 'banner' }, + ], + }, + ], { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 }, + }); + + const result = compareExpectedAnimations(config, 'hero'); + expect(result.expected).toBe(0); + }); + + it('counts effects within sequences', () => { + addKeyedElement('hero'); + const config = makeConfig([ + { + key: 'hero', trigger: 'viewEnter', + sequences: [{ effects: [{ effectId: 'fadeIn' }, { effectId: 'slideIn' }] }], + }, + ], { + fadeIn: { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 }, + slideIn: { keyframeEffect: { name: 'slide', keyframes: [{ transform: 'translateX(0)' }] }, duration: 500 }, + }); + + const result = compareExpectedAnimations(config, 'hero'); + expect(result.expected).toBe(2); + }); + }); + + describe('captureWarnings', () => { + it('captures console.warn calls during callback', () => { + const captured = captureWarnings(() => { + console.warn('test warning 1'); + console.warn('test warning 2'); + }); + + expect(captured).toHaveLength(2); + expect(captured[0].message).toBe('test warning 1'); + expect(captured[1].message).toBe('test warning 2'); + }); + + it('restores console.warn after callback', () => { + const original = console.warn; + captureWarnings(() => {}); + expect(console.warn).toBe(original); + }); + + it('restores console.warn even if callback throws', () => { + const original = console.warn; + expect(() => { + captureWarnings(() => { throw new Error('oops'); }); + }).toThrow('oops'); + expect(console.warn).toBe(original); + }); + + it('includes timestamp on captured warnings', () => { + const before = Date.now(); + const captured = captureWarnings(() => { + console.warn('test'); + }); + const after = Date.now(); + + expect(captured[0].timestamp).toBeGreaterThanOrEqual(before); + expect(captured[0].timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('captureWarningsAsync', () => { + it('captures warnings during async callback', async () => { + const captured = await captureWarningsAsync(async () => { + console.warn('async warning'); + }); + + expect(captured).toHaveLength(1); + expect(captured[0].message).toBe('async warning'); + }); + + it('restores console.warn after async callback', async () => { + const original = console.warn; + await captureWarningsAsync(async () => {}); + expect(console.warn).toBe(original); + }); + }); +}); From 1f72c8960c7de1e004e9c135163641b2ab99aed9 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 29 Apr 2026 13:33:11 +0300 Subject: [PATCH 3/5] wip --- packages/interact-debug/package.json | 8 +- packages/interact-debug/src/artifact.ts | 421 ++++++++++++++---- packages/interact-debug/src/cli.ts | 302 ++++++++++++- packages/interact-debug/src/index.ts | 37 +- .../src/inspect/configInspector.ts | 69 ++- .../src/inspect/domInspector.ts | 9 +- packages/interact-debug/src/inspect/index.ts | 18 +- .../src/inspect/runtimeValidator.ts | 29 +- packages/interact-debug/src/log/logger.ts | 24 +- packages/interact-debug/src/log/patcher.ts | 21 +- .../interact-debug/src/playwright/index.ts | 34 +- packages/interact-debug/src/types.ts | 67 ++- .../src/validate/antiPatterns.ts | 98 +++- .../src/validate/compatibilityValidator.ts | 180 ++++++-- .../src/validate/configValidator.ts | 368 ++++++++++++--- .../interact-debug/src/validate/helpers.ts | 33 +- packages/interact-debug/src/validate/index.ts | 8 +- .../src/validate/integrationValidator.ts | 262 ++++++----- .../src/validate/referenceValidator.ts | 112 ++++- .../src/validate/registryValidator.ts | 166 +++++-- .../interact-debug/test/antiPatterns.spec.ts | 348 +++++++++++---- packages/interact-debug/test/artifact.spec.ts | 102 ++++- .../test/compatibilityValidator.spec.ts | 344 ++++++++++---- .../test/configInspector.spec.ts | 73 ++- .../test/configValidator.spec.ts | 304 ++++++++++--- .../interact-debug/test/domInspector.spec.ts | 12 +- .../test/integrationValidator.spec.ts | 190 ++++++-- packages/interact-debug/test/logger.spec.ts | 7 +- .../test/referenceValidator.spec.ts | 130 ++++-- .../test/registryValidator.spec.ts | 147 +++--- .../test/runtimeValidator.spec.ts | 97 ++-- packages/interact-debug/vite.config.ts | 3 + packages/interact-debug/vitest.config.ts | 1 + packages/interact/rules/click.md | 4 +- packages/interact/rules/full-lean.md | 49 +- packages/interact/rules/hover.md | 4 +- packages/interact/rules/pointermove.md | 2 +- packages/interact/rules/viewenter.md | 12 +- packages/interact/rules/viewprogress.md | 2 + yarn.lock | 2 +- 40 files changed, 3174 insertions(+), 925 deletions(-) diff --git a/packages/interact-debug/package.json b/packages/interact-debug/package.json index 76e439c7..950a08b0 100644 --- a/packages/interact-debug/package.json +++ b/packages/interact-debug/package.json @@ -16,6 +16,11 @@ "types": "./dist/types/playwright/index.d.ts", "import": "./dist/es/playwright.js", "require": "./dist/cjs/playwright.js" + }, + "./eval": { + "types": "./dist/types/eval/index.d.ts", + "import": "./dist/es/eval.js", + "require": "./dist/cjs/eval.js" } }, "bin": "./dist/es/cli.js", @@ -27,7 +32,8 @@ "build": "rimraf dist && vite build && npm run build:types", "build:types": "tsc -p tsconfig.build.json", "lint": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "eval": "npx tsx bin/run-eval.ts" }, "keywords": [ "animation", diff --git a/packages/interact-debug/src/artifact.ts b/packages/interact-debug/src/artifact.ts index d0fe7c60..2ce75af9 100644 --- a/packages/interact-debug/src/artifact.ts +++ b/packages/interact-debug/src/artifact.ts @@ -1,6 +1,12 @@ import { JSDOM } from 'jsdom'; import type { InteractConfig } from './types'; -import type { InteractArtifact, ArtifactInput, FrameworkType } from './types'; +import type { + InteractArtifact, + ArtifactInput, + FrameworkType, + HtmlMetadata, + SetupMetadata, +} from './types'; /** * Parse any supported input into a unified InteractArtifact. @@ -18,27 +24,116 @@ export async function parseArtifact(input: ArtifactInput): Promise = {}; + for (const [k, v] of initialsMap) { + initials[k] = v; + } + + const interactElements: { key: string; hasChild: boolean }[] = []; + if (hasInteractElements(html)) { + const dom = new JSDOM(html); + const elements = dom.window.document.querySelectorAll('interact-element'); + for (const el of elements) { + const key = + el.getAttribute('data-interact-key') ?? + el.getAttribute('interact-key') ?? + el.getAttribute('key') ?? + ''; + interactElements.push({ key, hasChild: !!el.firstElementChild }); + } + + for (const ie of interactElements) { + if (ie.key && !keys.includes(ie.key)) { + keys.push(ie.key); + } + } + } + + return { keys, initials, interactElements }; +} + +/** + * Build structured JS setup metadata from a raw JS string. + * Uses simple string matching — inherently best-effort. + */ +export function buildSetupMetadata(js: string): SetupMetadata { + const meta: SetupMetadata = {}; + + meta.hasGenerate = /generate\s*\(/.test(js); + meta.hasDestroy = /(?:Interact\.destroy|\.destroy)\s*\(/.test(js); + meta.hasA11yTriggers = /Interact\.allowA11yTriggers\s*=\s*true/.test(js); + meta.hasRegisterEffects = js.includes('registerEffects'); + + if (meta.hasRegisterEffects) { + const registerPos = js.indexOf('registerEffects'); + const createPos = js.indexOf('Interact.create'); + if (createPos >= 0) { + meta.registerBeforeCreate = registerPos < createPos; + } + } + + if (/Interact\.setup\s*\(/.test(js)) { + const setupPos = js.indexOf('Interact.setup'); + const createPos = js.indexOf('Interact.create'); + if (createPos >= 0) { + meta.setupBeforeCreate = setupPos < createPos; + } + } + + return meta; +} + // --------------------------------------------------------------------------- // Separated // --------------------------------------------------------------------------- function parseSeparated(input: { config: InteractConfig; - html: string; + html?: string; css?: string; js?: string; + htmlMeta?: HtmlMetadata; + setupMeta?: SetupMetadata; + registeredEffects?: string[]; + framework?: FrameworkType; }): InteractArtifact { - const framework = input.js ? detectFramework(input.js) : undefined; - const registeredEffects = input.js ? extractRegisteredEffects(input.js) : undefined; + const hasPreParsed = !!( + input.htmlMeta || + input.setupMeta || + input.registeredEffects || + input.framework + ); + + const htmlMeta = input.htmlMeta ?? (input.html ? buildHtmlMetadata(input.html) : undefined); + const setupMeta = input.setupMeta ?? (input.js ? buildSetupMetadata(input.js) : undefined); + const framework = input.framework ?? (input.js ? detectFramework(input.js) : undefined); + const registeredEffects = + input.registeredEffects ?? (input.js ? extractRegisteredEffects(input.js) : undefined); return { config: input.config, - html: input.html, - css: input.css, - js: input.js, - framework, - registeredEffects, sourceType: 'separated', + htmlMeta, + setupMeta, + registeredEffects, + framework, + confidence: hasPreParsed ? 'high' : 'parsed', + raw: + input.html || input.css || input.js + ? { html: input.html, css: input.css, js: input.js } + : undefined, }; } @@ -54,17 +149,25 @@ function parseMixed(source: string): InteractArtifact { const js = extractJs(document); const config = extractConfig(js, document); const html = extractHtml(document); + + const htmlMeta = html ? buildHtmlMetadata(html) : undefined; + const setupMeta = js ? buildSetupMetadata(js) : undefined; const framework = js ? detectFramework(js) : undefined; const registeredEffects = js ? extractRegisteredEffects(js) : undefined; return { config, - html, - css: css || undefined, - js: js || undefined, - framework, - registeredEffects, sourceType: 'mixed', + htmlMeta, + setupMeta, + registeredEffects, + framework, + confidence: 'parsed', + raw: { + html: html || undefined, + css: css || undefined, + js: js || undefined, + }, }; } @@ -101,8 +204,12 @@ function mergeConfigs( } else if (Array.isArray(existing) && Array.isArray(value)) { merged[key] = [...existing, ...value]; } else if ( - existing !== null && typeof existing === 'object' && !Array.isArray(existing) && - value !== null && typeof value === 'object' && !Array.isArray(value) + existing !== null && + typeof existing === 'object' && + !Array.isArray(existing) && + value !== null && + typeof value === 'object' && + !Array.isArray(value) ) { merged[key] = { ...existing, ...(value as Record) }; } else { @@ -154,7 +261,6 @@ async function parseDirectory(dirPath: string): Promise { } } - // Merge config from all JSON files that look like interact configs for (const jsonFile of jsonFiles) { const content = await readFileSafe(fs, jsonFile); if (!content) continue; @@ -169,7 +275,6 @@ async function parseDirectory(dirPath: string): Promise { } } - // Merge all HTML files; prioritize those with interact markers but include all const interactHtmlFiles: string[] = []; const otherHtmlFiles: string[] = []; for (const htmlFile of htmlFiles) { @@ -187,12 +292,11 @@ async function parseDirectory(dirPath: string): Promise { const jsContent = jsParts.join('\n'); const cssContent = cssParts.join('\n'); - // If any HTML file contains inline scripts/styles, parse as mixed and merge if (htmlContent && (htmlContent.includes('; if (foundConfig) { @@ -201,19 +305,25 @@ async function parseDirectory(dirPath: string): Promise { finalConfig = mixedArtifact.config; } - const allJs = finalJs || mixedArtifact.js; + const allJs = finalJs || mixedArtifact.raw?.js; + const allHtml = mixedArtifact.raw?.html || htmlContent; + return { config: finalConfig as InteractConfig, - html: mixedArtifact.html, - css: finalCss || undefined, - js: finalJs || undefined, - framework: allJs ? detectFramework(allJs) : mixedArtifact.framework, - registeredEffects: allJs ? extractRegisteredEffects(allJs) : mixedArtifact.registeredEffects, sourceType: 'directory', + htmlMeta: allHtml ? buildHtmlMetadata(allHtml) : mixedArtifact.htmlMeta, + setupMeta: allJs ? buildSetupMetadata(allJs) : mixedArtifact.setupMeta, + registeredEffects: allJs ? extractRegisteredEffects(allJs) : mixedArtifact.registeredEffects, + framework: allJs ? detectFramework(allJs) : mixedArtifact.framework, + confidence: 'parsed', + raw: { + html: allHtml, + css: finalCss || undefined, + js: finalJs || undefined, + }, }; } - // Try extracting config from JS if none found in JSON if (!foundConfig && jsContent) { const fromJs = extractConfigFromJs(jsContent); if (fromJs) { @@ -228,17 +338,19 @@ async function parseDirectory(dirPath: string): Promise { ); } - const framework = jsContent ? detectFramework(jsContent) : undefined; - const registeredEffects = jsContent ? extractRegisteredEffects(jsContent) : undefined; - return { config: mergedConfig as InteractConfig, - html: htmlContent, - css: cssContent || undefined, - js: jsContent || undefined, - framework, - registeredEffects, sourceType: 'directory', + htmlMeta: htmlContent ? buildHtmlMetadata(htmlContent) : undefined, + setupMeta: jsContent ? buildSetupMetadata(jsContent) : undefined, + registeredEffects: jsContent ? extractRegisteredEffects(jsContent) : undefined, + framework: jsContent ? detectFramework(jsContent) : undefined, + confidence: 'parsed', + raw: { + html: htmlContent || undefined, + css: cssContent || undefined, + js: jsContent || undefined, + }, }; } @@ -261,7 +373,7 @@ function joinParts(a: string | undefined, b: string | undefined): string | undef } // --------------------------------------------------------------------------- -// Extraction helpers +// DOM extraction helpers (used when parsing mixed blobs / directories) // --------------------------------------------------------------------------- function extractCss(document: Document): string { @@ -280,12 +392,14 @@ function extractJs(document: Document): string { const scripts: string[] = []; const scriptTags = document.querySelectorAll('script'); for (const tag of scriptTags) { - if (tag.src && (tag.src.includes('cdn') || tag.src.includes('unpkg') || tag.src.includes('jsdelivr'))) { + if ( + tag.src && + (tag.src.includes('cdn') || tag.src.includes('unpkg') || tag.src.includes('jsdelivr')) + ) { tag.remove(); continue; } if (tag.type === 'application/json') { - // Leave in DOM for extractConfig to find, but don't collect as JS continue; } if (tag.textContent) { @@ -312,15 +426,11 @@ function extractHtml(document: Document): string { * 1. Interact.create(...) call with inline object or variable reference * 2. Variable with InteractConfig type annotation * 3. + + +``` ### What's happening and why | Concern | Decision | |---|---| | **FOUC — heading** | CSS +`opacity: 0` in ` + ` (equivalent to `generate(config)` server output) + `data-interact-initial="true"` so the runtime + coordinates keyframe application. `fill: 'both'` holds the final visible state. | | **FOUC — + icon** | Manual `opacity: 0; transform: scale(0)` CSS. `generate()` only covers `viewEnter + once` + same-element cases, so the icon needs an explicit guard. `ExpandIn` + `fill: 'both'` takes over + and holds opacity/scale after the animation ends. | | **Chaining** | `effectId: 'heading-fade-in'` + on the `FadeIn` effect is the stable handle. The `animationEnd` interaction references that same + ID via `params.effectId` — the runtime fires it the moment the heading's `FadeIn` completes. | | + **`triggerType: 'once'`** | Mandatory when source and target are the same element on `viewEnter`; + animating the observed element could push it out of the viewport and cause rapid re-triggers. | | + **Reduced motion** | `conditions: ['reduced-motion']` on both interactions suppresses all + animations. The `@media (prefers-reduced-motion: reduce)` CSS overrides the initial-hidden rules + so both elements remain visible without any JS involvement. | | **`registerEffects` / `create` / + `destroy`** | `registerEffects` runs first (required before `create` when using `namedEffect`). + `instance.destroy()` in `beforeunload` cleans up all observers and listeners. | + diff --git a/packages/interact-debug/eval-raw-output/hover-interest.html b/packages/interact-debug/eval-raw-output/hover-interest.html new file mode 100644 index 00000000..dc09ef00 --- /dev/null +++ b/packages/interact-debug/eval-raw-output/hover-interest.html @@ -0,0 +1,299 @@ +```html + + + + + + Card Hover Scale — @wix/interact + + + + + +
+
+
+ + @wix/interact +
+

Scale on Hover

+

+ Mouse over or focus this card for a smooth scale-up animation. Uses + triggerType "alternate" so it reverses gracefully on mouse-leave + or blur. +

+ + +
+
+
+ + + + + + +``` ### Key decisions & rules applied | Rule | Implementation | |---|---| | **Hit-area shift** | +`.card-outer` is the hover source (never transforms); `.card-inner` is the animation target, +resolved via `selector: '.card-inner'` on the shared effect. This prevents the scaled element from +shifting the pointer hit-area and causing flicker. | | **`fill: 'both'`** | Required for +`triggerType: 'alternate'` so the animation holds its state between hover enter/leave. | | **a11y +pairing** | `hover` + `interest` interactions share the same `effectId` reference. +`Interact.allowA11yTriggers = true` is set before `create()`. `.card-outer` has `tabindex="0"` so it +receives focus events. | | **Reduced motion** | `conditions: ['reduced-motion']` on both +interactions suppresses the animation when `prefers-reduced-motion: reduce` is active. | | **Shared +effect registry** | The `'card-scale-up'` entry in `effects` is defined once and referenced by both +interactions — no duplication. | | **Destroy** | `instance.destroy()` is called on `window unload` +to clean up all listeners and running animations. | diff --git a/packages/interact-debug/eval-raw-output/pointerMove-tracking.html b/packages/interact-debug/eval-raw-output/pointerMove-tracking.html new file mode 100644 index 00000000..1c8afa49 --- /dev/null +++ b/packages/interact-debug/eval-raw-output/pointerMove-tracking.html @@ -0,0 +1,253 @@ + + + + + + 3D Tilt Card — @wix/interact + + + + +
+
+
+
+
+
+

Wix Interact

+

Real-time 3D tilt driven
by your mouse position

+
+
+
+ pointerMove + Tilt3DMouse +
+
+
+
+ +

Move your cursor over the card

+ + + + diff --git a/packages/interact-debug/eval-raw-output/reduced-motion.html b/packages/interact-debug/eval-raw-output/reduced-motion.html new file mode 100644 index 00000000..3713f54f --- /dev/null +++ b/packages/interact-debug/eval-raw-output/reduced-motion.html @@ -0,0 +1,280 @@ +```html + + + + + + FadeIn on ViewEnter — @wix/interact + + + + + + + + + +
+ @wix/interact demo +

Scroll down to see the animation

+

+ The heading below fades in the moment it enters the viewport, with full reduced-motion + support. +

+ +
+ + +
+

viewEnter · FadeIn · once

+ + + +

Hello, World.

+
+ +

+ Powered by @wix/interact with a FadeIn preset from + @wix/motion-presets. Automatically disabled when + prefers-reduced-motion: reduce is set. +

+
+ +
+ + + + + +``` diff --git a/packages/interact-debug/eval-raw-output/viewEnter-alternate.html b/packages/interact-debug/eval-raw-output/viewEnter-alternate.html new file mode 100644 index 00000000..0fe93db3 --- /dev/null +++ b/packages/interact-debug/eval-raw-output/viewEnter-alternate.html @@ -0,0 +1,425 @@ +```html + + + + + + Scroll In / Out Card — @wix/interact + + + + +
+

@wix/interact demo

+

Scroll to
Reveal

+

+ The card below glides in as it enters the viewport and glides back out when you scroll past + it. +

+ +
+ + +
+ + +
+ + +
+ ✦ Featured +

Scroll-driven
Entrance

+

+ Built with viewEnter + triggerType 'alternate' and + the GlideIn preset. Animates in on viewport entry and reverses out + on exit — GPU-accelerated and accessible by default. +

+ +
+
+
60fps
+
Smooth
+
+
+
GPU
+
Accelerated
+
+
+
A11y
+
Accessible
+
+
+ + +
+
+
+
+
+ + + + + + + +``` Here's a breakdown of every key decision made: ### Why separate source (`card-source`) and +target (`card`) elements The **CRITICAL** rule for `viewEnter` + `triggerType: 'alternate'` forbids +using the same element as both trigger source and animation target. If the card itself were +observed, the `GlideIn` transform would push it out of the viewport mid-animation, causing an +infinite enter/exit loop. The outer ` +
+ ` is observed; the inner `.card` is animated. ### Why `opacity: 0` is set in CSS (no + `data-interact-initial`) `data-interact-initial` (FOUC prevention) is **only valid** for + `triggerType: 'once'`. For `alternate`, the starting keyframe must be applied manually. Setting + `opacity: 0` in CSS ensures the card is invisible before the animation attaches. `fill: 'both'` + then takes over and holds both the start and end keyframes during the animation lifecycle. ### Why + `fill: 'both'` Required for `alternate` — `backwards` applies the start keyframe during any delay, + and `forwards` holds the final visible state while the card is in view. ### `reduced-motion` + condition Placed on the interaction (not just the effect) to gate the **entire trigger**, so no + animation runs at all for users with `prefers-reduced-motion: reduce`. ### `registerEffects()` → + `Interact.create()` → `destroy()` order Presets must be registered before `create()` or + `namedEffect: { type: 'GlideIn' }` won't resolve. The instance is stored and destroyed on + `beforeunload` to prevent stale listeners. +
diff --git a/packages/interact-debug/eval-raw-output/viewEnter-once.html b/packages/interact-debug/eval-raw-output/viewEnter-once.html new file mode 100644 index 00000000..cafbfb6d --- /dev/null +++ b/packages/interact-debug/eval-raw-output/viewEnter-once.html @@ -0,0 +1,224 @@ +```html + + + + + + Scroll Fade-In · @wix/interact + + + + + +
+ @wix/interact demo +

Scroll to reveal

+ +
+ + + +
+
+ viewEnter · once +

Fades in on scroll.

+

+ Triggered by viewEnter with triggerType: 'once', + this section uses the FadeIn preset from + @wix/motion-presets. FOUC is prevented via + generate(config) and data-interact-initial. +

+
+
+
+ + + + + +``` + +Key decisions made: + +| Choice | Reason | +|---|---| +| `FadeIn` preset | Canonical entrance preset; semantically matches `viewEnter` trigger | +| `triggerType: 'once'` | Source and target are the **same** element — required by the rules | +| `fill: 'backwards'` | `FadeIn` ends at natural `opacity: 1`, so only the backwards fill (applying `opacity: 0` during delay) is needed | +| `threshold: 0.2` | Animation fires when 20% of the section enters the viewport — avoids triggering on a single pixel | +| `conditions: ['reduced-motion']` | Gates the entire entrance interaction for users with `prefers-reduced-motion: reduce` | +| Dual FOUC guard | Static CSS in ` + + + ${html} + + +`; + + await fs.writeFile(path.join(tmpDir, 'index.html'), indexHtml); + await fs.writeFile(path.join(tmpDir, 'main.js'), mainJs); + + const interactSrc = resolvePeerSource(path, 'interact'); + const motionSrc = resolvePeerSource(path, 'motion'); + + const alias: Record = {}; + if (interactSrc) alias['@wix/interact'] = interactSrc; + if (motionSrc) alias['@wix/motion'] = motionSrc; + + const server = await createServer({ + root: tmpDir, + resolve: { alias }, + server: { port: 0, strictPort: false }, + logLevel: 'silent', + }); + + await server.listen(); + const address = server.httpServer?.address(); + const port = typeof address === 'object' && address ? address.port : 5199; + const url = `http://localhost:${port}`; + + return { + url, + cleanup: async () => { + await server.close(); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }, + }; +} + +function resolvePeerSource( + pathMod: typeof import('node:path'), + pkg: 'interact' | 'motion', +): string | undefined { + try { + const thisDir = pathMod.dirname(new URL(import.meta.url).pathname); + return pathMod.resolve(thisDir, `../../../${pkg}/src/index.ts`); + } catch { + return undefined; + } +} diff --git a/packages/interact-debug/src/playwright/performanceScorer.ts b/packages/interact-debug/src/playwright/performanceScorer.ts new file mode 100644 index 00000000..8daedf3b --- /dev/null +++ b/packages/interact-debug/src/playwright/performanceScorer.ts @@ -0,0 +1,135 @@ +import type { Page } from '@playwright/test'; +import type { InteractArtifact, ScoreResult, Scope } from '../types'; +import { isInScope } from '../validate/helpers'; +import { weightedAverage } from '../score/utils'; +import { fireTrigger } from './triggerHelpers'; + +/** + * Runtime performance scorer. Measures: + * - CLS (Cumulative Layout Shift) during animations + * - Whether animations use compositor-only properties + * - Long Animation Frames count + */ +export async function scorePerformance( + page: Page, + artifact: InteractArtifact, + scope?: Scope, +): Promise { + const subscores: ScoreResult[] = []; + + // 1. Install CLS observer before triggering + await page.evaluate(() => { + (window as any).__cls = 0; + (window as any).__clsObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!(entry as any).hadRecentInput) { + (window as any).__cls += (entry as any).value; + } + } + }); + (window as any).__clsObserver.observe({ type: 'layout-shift', buffered: true }); + }); + + // 2. Install long animation frame observer + await page.evaluate(() => { + (window as any).__longFrames = 0; + try { + (window as any).__longFrameObserver = new PerformanceObserver((list) => { + (window as any).__longFrames += list.getEntries().length; + }); + (window as any).__longFrameObserver.observe({ type: 'long-animation-frame', buffered: true }); + } catch { + // LoAF not supported in all browsers + } + }); + + // 3. Fire all triggers in scope + const interactions = scope + ? artifact.config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : artifact.config.interactions; + + for (const ix of interactions) { + if (ix.trigger === 'animationEnd') continue; + await fireTrigger(page, ix.trigger, ix.key); + await page.waitForTimeout(200); + } + + // Wait for animations to settle + await page.waitForTimeout(1000); + + // 4. Collect CLS + const cls = await page.evaluate(() => { + (window as any).__clsObserver?.disconnect(); + return (window as any).__cls ?? 0; + }); + + const clsScore = cls <= 0.01 ? 1 : cls <= 0.1 ? 0.7 : cls <= 0.25 ? 0.4 : 0; + subscores.push({ + dimension: 'cumulativeLayoutShift', + score: clsScore, + weight: 0.35, + details: `CLS: ${cls.toFixed(4)}`, + }); + + // 5. Collect long animation frames + const longFrames = await page.evaluate(() => { + (window as any).__longFrameObserver?.disconnect(); + return (window as any).__longFrames ?? 0; + }); + + const longFrameScore = longFrames === 0 ? 1 : Math.max(0, 1 - longFrames * 0.2); + subscores.push({ + dimension: 'longAnimationFrames', + score: longFrameScore, + weight: 0.3, + details: `${longFrames} long animation frame(s)`, + }); + + // 6. Compositor-only property check via live animations + const compositorRatio = await page.evaluate(() => { + const allAnims = document.getAnimations(); + let compositor = 0; + let total = 0; + const compositorProps = new Set([ + 'transform', + 'opacity', + 'filter', + 'backdrop-filter', + 'clip-path', + 'offset-distance', + 'translate', + 'rotate', + 'scale', + ]); + + for (const anim of allAnims) { + if (!anim.effect || typeof (anim.effect as any).getKeyframes !== 'function') continue; + const keyframes = (anim.effect as any).getKeyframes(); + for (const frame of keyframes) { + for (const key of Object.keys(frame)) { + if (['offset', 'easing', 'composite', 'computedOffset'].includes(key)) continue; + total++; + const kebab = key.replace(/[A-Z]/g, (m: string) => `-${m.toLowerCase()}`); + if (compositorProps.has(kebab) || compositorProps.has(key)) compositor++; + } + } + } + return total > 0 ? compositor / total : 1; + }); + + subscores.push({ + dimension: 'compositorProperties', + score: compositorRatio, + weight: 0.35, + details: `${(compositorRatio * 100).toFixed(0)}% compositor-friendly properties in live animations`, + }); + + const score = weightedAverage(subscores); + return { + dimension: 'performance', + score, + weight: 0.15, + details: 'Runtime performance: CLS, long frames, compositor properties', + subscores, + }; +} diff --git a/packages/interact-debug/src/playwright/pointerHelpers.ts b/packages/interact-debug/src/playwright/pointerHelpers.ts new file mode 100644 index 00000000..c3fa4049 --- /dev/null +++ b/packages/interact-debug/src/playwright/pointerHelpers.ts @@ -0,0 +1,49 @@ +import type { Page } from '@playwright/test'; + +/** + * Move the pointer to hover over a keyed element's center. + */ +export async function hoverElement(page: Page, key: string): Promise { + const loc = page.locator(`[data-interact-key="${key}"]`); + await loc.scrollIntoViewIfNeeded(); + await loc.hover(); + await page.waitForTimeout(50); +} + +/** + * Move the pointer away from a keyed element (to page corner). + */ +export async function unhoverElement(page: Page): Promise { + await page.mouse.move(0, 0); + await page.waitForTimeout(50); +} + +/** + * Click on a keyed element. + */ +export async function clickElement(page: Page, key: string): Promise { + const loc = page.locator(`[data-interact-key="${key}"]`); + await loc.scrollIntoViewIfNeeded(); + await loc.click(); + await page.waitForTimeout(50); +} + +/** + * Move the pointer to a specific position within a keyed element, + * expressed as ratios (0–1) of the element's bounding box. + */ +export async function movePointerWithinElement( + page: Page, + key: string, + xRatio: number, + yRatio: number, +): Promise { + const loc = page.locator(`[data-interact-key="${key}"]`); + await loc.scrollIntoViewIfNeeded(); + const box = await loc.boundingBox(); + if (!box) return; + const x = box.x + box.width * xRatio; + const y = box.y + box.height * yRatio; + await page.mouse.move(x, y); + await page.waitForTimeout(50); +} diff --git a/packages/interact-debug/src/playwright/runtimeVerifier.ts b/packages/interact-debug/src/playwright/runtimeVerifier.ts new file mode 100644 index 00000000..35f11df1 --- /dev/null +++ b/packages/interact-debug/src/playwright/runtimeVerifier.ts @@ -0,0 +1,282 @@ +import type { Page } from '@playwright/test'; +import type { InteractArtifact, Scope, TriggerType } from '../types'; +import { isRecord, isInScope, resolveEffect, buildGlobalMaps } from '../validate/helpers'; +import { fireTrigger, reverseTrigger } from './triggerHelpers'; +import { waitForAnimationState, getComputedStyleProp } from './animationHelpers'; + +export type VerificationCheck = { + name: string; + passed: boolean; + expected?: string; + actual?: string; +}; + +export type VerificationResult = { + interaction: { key: string; trigger: TriggerType; index: number }; + passed: boolean; + checks: VerificationCheck[]; +}; + +/** + * Verify all interactions in the artifact by firing triggers and checking + * that animated styles actually change in the browser. + */ +export async function verifyAll( + page: Page, + artifact: InteractArtifact, + scope?: Scope, +): Promise { + const results: VerificationResult[] = []; + + for (let i = 0; i < artifact.config.interactions.length; i++) { + const ix = artifact.config.interactions[i]; + if (!isInScope(ix, i, scope)) continue; + results.push(await verifyInteraction(page, artifact, i)); + } + + return results; +} + +/** + * Verify a single interaction by index. + */ +export async function verifyInteraction( + page: Page, + artifact: InteractArtifact, + index: number, +): Promise { + const ix = artifact.config.interactions[index]; + const { globalEffects } = buildGlobalMaps(artifact.config); + const checks: VerificationCheck[] = []; + + const targetKey = ix.key; + const animatedProps = extractAnimatedProperties(ix, globalEffects); + const effectTypes = classifyEffects(ix, globalEffects); + + // 1. Check DOM element exists + const exists = await page.evaluate((key) => { + return !!document.querySelector(`[data-interact-key="${key}"]`); + }, targetKey); + checks.push({ + name: 'element-exists', + passed: exists, + expected: 'present', + actual: exists ? 'found' : 'missing', + }); + + if (!exists) { + return { interaction: { key: targetKey, trigger: ix.trigger, index }, passed: false, checks }; + } + + // 2. Record baseline styles + const baseline: Record = {}; + for (const prop of animatedProps) { + baseline[prop] = await getComputedStyleProp(page, targetKey, prop); + } + + // 3. Fire trigger + await fireTrigger(page, ix.trigger, targetKey); + + // 4. Wait for animation to start (time/scrub effects) or state to change + if (effectTypes.has('time') || effectTypes.has('scrub')) { + try { + await waitForAnimationState(page, targetKey, ['running', 'finished'], 3000); + checks.push({ name: 'animation-started', passed: true }); + } catch { + checks.push({ + name: 'animation-started', + passed: false, + expected: 'running or finished', + actual: 'no animation detected', + }); + } + } + + // Small settle time for state effects + await page.waitForTimeout(300); + + // 5. Check that at least one animated property changed + if (animatedProps.length > 0) { + let anyChanged = false; + for (const prop of animatedProps) { + const current = await getComputedStyleProp(page, targetKey, prop); + if (current !== baseline[prop]) { + anyChanged = true; + break; + } + } + checks.push({ + name: 'style-changed', + passed: anyChanged, + expected: 'at least one property changed', + actual: anyChanged ? 'changed' : 'no change detected', + }); + } + + // 6. For state effects, check data attribute toggle + if (effectTypes.has('state')) { + const hasEffectAttr = await page.evaluate((key) => { + const el = document.querySelector(`[data-interact-key="${key}"]`); + return el ? el.hasAttribute('data-interact-effect') : false; + }, targetKey); + checks.push({ + name: 'state-attribute', + passed: hasEffectAttr, + expected: 'data-interact-effect present', + actual: hasEffectAttr ? 'present' : 'missing', + }); + } + + // 7. For alternate triggerType, reverse and check return to baseline + const isAlternate = getEffectTriggerTypes(ix, globalEffects).includes('alternate'); + if (isAlternate) { + await reverseTrigger(page, ix.trigger, targetKey); + await page.waitForTimeout(500); + + let returned = true; + for (const prop of animatedProps) { + const current = await getComputedStyleProp(page, targetKey, prop); + if (current !== baseline[prop]) { + returned = false; + break; + } + } + checks.push({ + name: 'alternate-reset', + passed: returned, + expected: 'styles return to baseline', + actual: returned ? 'returned' : 'did not return', + }); + } + + // 8. For scrub effects, check multiple progress points produce different styles + if (effectTypes.has('scrub') && ix.trigger === 'viewProgress') { + const { scrollToProgress } = await import('./scrollHelpers'); + const stylesAtPoints: string[] = []; + for (const progress of [0, 0.5, 1]) { + await scrollToProgress(page, targetKey, progress); + await page.waitForTimeout(200); + const vals = await Promise.all( + animatedProps.map((p) => getComputedStyleProp(page, targetKey, p)), + ); + stylesAtPoints.push(vals.join('|')); + } + const uniqueStyles = new Set(stylesAtPoints).size; + checks.push({ + name: 'scrub-variation', + passed: uniqueStyles > 1, + expected: 'different styles at different scroll positions', + actual: `${uniqueStyles} unique style states across 3 points`, + }); + } + + return { + interaction: { key: targetKey, trigger: ix.trigger, index }, + passed: checks.every((c) => c.passed), + checks, + }; +} + +/** + * Verify all interactions for a given key. + */ +export async function verifyKey( + page: Page, + artifact: InteractArtifact, + key: string, +): Promise { + return verifyAll(page, artifact, { key }); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function extractAnimatedProperties( + interaction: Record, + globalEffects: Record>, +): string[] { + const props = new Set(); + const effects = Array.isArray(interaction.effects) ? interaction.effects : []; + + for (const raw of effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + const targetKey = typeof eff.key === 'string' ? eff.key : interaction.key; + if (targetKey !== interaction.key) continue; + + if (isRecord(eff.keyframeEffect)) { + const kf = eff.keyframeEffect as Record; + if (Array.isArray(kf.keyframes)) { + for (const frame of kf.keyframes) { + if (!isRecord(frame)) continue; + for (const key of Object.keys(frame as Record)) { + if (key === 'offset' || key === 'easing' || key === 'composite') continue; + props.add(camelToKebab(key)); + } + } + } + } + + if (isRecord(eff.transition)) { + const trans = eff.transition as Record; + for (const key of Object.keys(trans)) { + props.add(camelToKebab(key)); + } + } + + if (Array.isArray(eff.transitionProperties)) { + for (const tp of eff.transitionProperties) { + if (isRecord(tp) && typeof (tp as Record).property === 'string') { + props.add(camelToKebab((tp as Record).property as string)); + } + } + } + + if (isRecord(eff.namedEffect)) { + props.add('transform'); + props.add('opacity'); + } + } + + return [...props]; +} + +function classifyEffects( + interaction: Record, + globalEffects: Record>, +): Set<'time' | 'scrub' | 'state'> { + const types = new Set<'time' | 'scrub' | 'state'>(); + const effects = Array.isArray(interaction.effects) ? interaction.effects : []; + + for (const raw of effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + if ('transition' in eff || 'transitionProperties' in eff) types.add('state'); + else if ('rangeStart' in eff || 'rangeEnd' in eff) types.add('scrub'); + else if ('keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff) + types.add('time'); + } + + return types; +} + +function getEffectTriggerTypes( + interaction: Record, + globalEffects: Record>, +): string[] { + const types: string[] = []; + const effects = Array.isArray(interaction.effects) ? interaction.effects : []; + + for (const raw of effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + if (typeof eff.triggerType === 'string') types.push(eff.triggerType); + } + + return types; +} + +function camelToKebab(str: string): string { + return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} diff --git a/packages/interact-debug/src/playwright/scrollHelpers.ts b/packages/interact-debug/src/playwright/scrollHelpers.ts new file mode 100644 index 00000000..87eacc3a --- /dev/null +++ b/packages/interact-debug/src/playwright/scrollHelpers.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test'; + +/** + * Scroll the keyed element into the viewport center. + */ +export async function scrollToKey(page: Page, key: string): Promise { + await page.evaluate((key) => { + const el = document.querySelector(`[data-interact-key="${key}"]`); + if (el) el.scrollIntoView({ block: 'center', behavior: 'instant' }); + }, key); + await page.waitForTimeout(150); +} + +/** + * Scroll the viewport by deltaY pixels. + */ +export async function scrollBy(page: Page, deltaY: number): Promise { + await page.evaluate((dy) => window.scrollBy(0, dy), deltaY); + await page.waitForTimeout(100); +} + +/** + * Scroll so a viewProgress element is at approximately `progress` (0–1). + * + * Assumes the element's scroll range starts when its top enters the viewport + * bottom and ends when its bottom exits the viewport top. + */ +export async function scrollToProgress(page: Page, key: string, progress: number): Promise { + await page.evaluate( + ({ key, progress }) => { + const el = document.querySelector(`[data-interact-key="${key}"]`); + if (!el) return; + const rect = el.getBoundingClientRect(); + const viewH = window.innerHeight; + const elTop = rect.top + window.scrollY; + const totalRange = viewH + rect.height; + const start = elTop - viewH; + const target = start + totalRange * progress; + window.scrollTo({ top: target, behavior: 'instant' }); + }, + { key, progress }, + ); + await page.waitForTimeout(150); +} diff --git a/packages/interact-debug/src/playwright/triggerHelpers.ts b/packages/interact-debug/src/playwright/triggerHelpers.ts new file mode 100644 index 00000000..3afb6b3f --- /dev/null +++ b/packages/interact-debug/src/playwright/triggerHelpers.ts @@ -0,0 +1,77 @@ +import type { Page } from '@playwright/test'; +import type { TriggerType } from '../types'; +import { + hoverElement, + unhoverElement, + clickElement, + movePointerWithinElement, +} from './pointerHelpers'; +import { scrollToKey, scrollToProgress, scrollBy } from './scrollHelpers'; + +/** + * Fire a trigger action on a keyed element. + * Dispatches the appropriate browser action for each trigger type. + */ +export async function fireTrigger(page: Page, trigger: TriggerType, key: string): Promise { + switch (trigger) { + case 'hover': + case 'interest': + await hoverElement(page, key); + break; + + case 'click': + case 'activate': + await clickElement(page, key); + break; + + case 'viewEnter': + case 'pageVisible': + await scrollToKey(page, key); + break; + + case 'viewProgress': + await scrollToProgress(page, key, 0.5); + break; + + case 'pointerMove': + await movePointerWithinElement(page, key, 0.5, 0.5); + break; + + case 'animationEnd': + // No-op: animationEnd fires automatically when a previous animation finishes + break; + } +} + +/** + * Reverse/undo a trigger action (for alternate/state testing). + */ +export async function reverseTrigger(page: Page, trigger: TriggerType, key: string): Promise { + switch (trigger) { + case 'hover': + case 'interest': + await unhoverElement(page); + break; + + case 'click': + case 'activate': + await clickElement(page, key); + break; + + case 'viewEnter': + case 'pageVisible': + await scrollBy(page, -500); + break; + + case 'viewProgress': + await scrollToProgress(page, key, 0); + break; + + case 'pointerMove': + await movePointerWithinElement(page, key, 0, 0); + break; + + case 'animationEnd': + break; + } +} diff --git a/packages/interact-debug/src/score/a11yScorer.ts b/packages/interact-debug/src/score/a11yScorer.ts new file mode 100644 index 00000000..6a9882b2 --- /dev/null +++ b/packages/interact-debug/src/score/a11yScorer.ts @@ -0,0 +1,130 @@ +import type { InteractArtifact, ScoreResult, Scope, TriggerType } from '../types'; +import { isRecord, isInScope, resolveEffect, buildGlobalMaps } from '../validate/helpers'; +import { weightedAverage } from './utils'; + +/** + * Scores accessibility best practices: + * - `activate` for every `click` (keyboard support) + * - `interest` for every `hover` (focus support) + * - `prefers-reduced-motion` condition defined and used + * - State effects use toggle action (keyboard-friendly) + * - allowA11yTriggers set when a11y triggers are used + */ +export function scoreA11y(artifact: InteractArtifact, scope?: Scope): ScoreResult { + const { config, setupMeta } = artifact; + const subscores: ScoreResult[] = []; + + const interactions = scope + ? config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : config.interactions; + + // Build key→triggers map + const triggersByKey = new Map>(); + for (const ix of interactions) { + const set = triggersByKey.get(ix.key) ?? new Set(); + set.add(ix.trigger); + triggersByKey.set(ix.key, set); + } + + // Click vs activate: activate should be used INSTEAD OF click + let clickCount = 0; + let activateCount = 0; + for (const [, triggers] of triggersByKey) { + if (triggers.has('click')) clickCount++; + if (triggers.has('activate')) activateCount++; + } + const totalClickLike = clickCount + activateCount; + const activateScore = totalClickLike === 0 ? 1 : activateCount / totalClickLike; + subscores.push({ + dimension: 'clickActivatePairing', + score: activateScore, + weight: 0.3, + details: `${activateCount}/${totalClickLike} click-like keys use activate`, + }); + + // Hover vs interest: interest should be used INSTEAD OF hover + let hoverCount = 0; + let interestCount = 0; + for (const [, triggers] of triggersByKey) { + if (triggers.has('hover')) hoverCount++; + if (triggers.has('interest')) interestCount++; + } + const totalHoverLike = hoverCount + interestCount; + const interestScore = totalHoverLike === 0 ? 1 : interestCount / totalHoverLike; + subscores.push({ + dimension: 'hoverInterestPairing', + score: interestScore, + weight: 0.3, + details: `${interestCount}/${totalHoverLike} hover-like keys use interest`, + }); + + // prefers-reduced-motion condition + const conditions = config.conditions ?? {}; + const hasReducedMotion = Object.values(conditions).some((c) => { + if (!isRecord(c)) return false; + const cond = c as Record; + return ( + cond.type === 'media' && + typeof cond.predicate === 'string' && + cond.predicate.includes('prefers-reduced-motion') + ); + }); + const rmScore = hasReducedMotion ? 1 : 0; + subscores.push({ + dimension: 'reducedMotion', + score: rmScore, + weight: 0.2, + details: hasReducedMotion + ? 'prefers-reduced-motion condition present' + : 'No prefers-reduced-motion condition', + }); + + // State effects use toggle action + const { globalEffects } = buildGlobalMaps(config); + let stateEffects = 0; + let toggleActions = 0; + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const raw of ix.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + if ('transition' in eff || 'transitionProperties' in eff) { + stateEffects++; + if (eff.stateAction === 'toggle') toggleActions++; + } + } + } + const toggleScore = stateEffects === 0 ? 1 : toggleActions / stateEffects; + subscores.push({ + dimension: 'stateToggle', + score: toggleScore, + weight: 0.1, + details: `${toggleActions}/${stateEffects} state effects use toggle`, + }); + + // allowA11yTriggers set when needed + const needsA11y = interactions.some( + (ix) => ix.trigger === 'activate' || ix.trigger === 'interest', + ); + let a11yTriggersScore = 1; + if (needsA11y && setupMeta) { + a11yTriggersScore = setupMeta.hasA11yTriggers ? 1 : 0; + } else if (needsA11y && !setupMeta) { + a11yTriggersScore = 0.5; // unknown + } + subscores.push({ + dimension: 'allowA11yTriggers', + score: a11yTriggersScore, + weight: 0.1, + details: needsA11y ? (setupMeta?.hasA11yTriggers ? 'Set' : 'Not set') : 'Not needed', + }); + + const score = weightedAverage(subscores); + return { + dimension: 'a11y', + score, + weight: 0.2, + details: `Accessibility score based on trigger pairing, reduced motion, and state actions`, + subscores, + }; +} diff --git a/packages/interact-debug/src/score/aggregate.ts b/packages/interact-debug/src/score/aggregate.ts new file mode 100644 index 00000000..ab27988f --- /dev/null +++ b/packages/interact-debug/src/score/aggregate.ts @@ -0,0 +1,68 @@ +import type { + InteractConfig, + InteractArtifact, + ValidationResult, + ScoreResult, + ScoreReport, + Scope, +} from '../types'; +import { scoreComplexity } from './complexityScorer'; +import { scoreWeight } from './weightScorer'; +import { scoreA11y } from './a11yScorer'; +import { scoreCoherence } from './coherenceScorer'; +import { scoreBestPractices } from './bestPracticesScorer'; +import { scoreValidation } from './validationScorer'; + +/** + * Score a config-only input (no HTML/JS metadata). + * Runs complexity, weight, and coherence scorers. + */ +export function scoreConfig(config: InteractConfig, scope?: Scope): ScoreReport { + const dimensions: ScoreResult[] = [ + scoreComplexity(config, scope), + scoreWeight(config, scope), + scoreCoherence(config, scope), + ]; + return buildReport(dimensions); +} + +/** + * Score a full artifact (config + metadata) with its validation results. + * Runs all static scorers including a11y, bestPractices, and validation. + * + * When validationResult is provided, the validation dimension uses ALL + * validator errors/warnings (schema, reference, compatibility, integration, + * anti-patterns, registry) to produce a single penalized score. + */ +export function scoreArtifact( + artifact: InteractArtifact, + scope?: Scope, + validationResult?: ValidationResult, +): ScoreReport { + const dimensions: ScoreResult[] = [ + scoreComplexity(artifact.config, scope), + scoreWeight(artifact.config, scope), + scoreA11y(artifact, scope), + scoreCoherence(artifact.config, scope), + scoreBestPractices(artifact, scope), + ]; + + if (validationResult) { + dimensions.push(scoreValidation(validationResult)); + } + + return buildReport(dimensions); +} + +function buildReport(dimensions: ScoreResult[]): ScoreReport { + let totalWeight = 0; + let weightedSum = 0; + for (const dim of dimensions) { + weightedSum += dim.score * dim.weight; + totalWeight += dim.weight; + } + return { + aggregate: totalWeight > 0 ? weightedSum / totalWeight : 0, + dimensions, + }; +} diff --git a/packages/interact-debug/src/score/bestPracticesScorer.ts b/packages/interact-debug/src/score/bestPracticesScorer.ts new file mode 100644 index 00000000..e94143ba --- /dev/null +++ b/packages/interact-debug/src/score/bestPracticesScorer.ts @@ -0,0 +1,152 @@ +import type { InteractArtifact, ScoreResult, Scope } from '../types'; +import { isRecord, isInScope, resolveEffect, buildGlobalMaps } from '../validate/helpers'; +import { detectAntiPatterns } from '../validate/antiPatterns'; +import { weightedAverage } from './utils'; + +/** + * Scores adherence to Interact best practices: + * - Anti-pattern count + * - FOUC prevention for viewEnter+once + * - Fill usage (forwards for once, both for alternate) + * - effectId usage for shared effects + * - Cleanup code (destroy) + */ +export function scoreBestPractices(artifact: InteractArtifact, scope?: Scope): ScoreResult { + const { config, setupMeta, htmlMeta } = artifact; + const { globalEffects } = buildGlobalMaps(config); + const subscores: ScoreResult[] = []; + + const interactions = scope + ? config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : config.interactions; + + // Anti-pattern count + const antiResult = detectAntiPatterns(artifact, scope); + const antiCount = antiResult.warnings.length; + const antiScore = antiCount === 0 ? 1 : Math.max(0, 1 - antiCount * 0.15); + subscores.push({ + dimension: 'antiPatterns', + score: antiScore, + weight: 0.25, + details: `${antiCount} anti-patterns detected`, + }); + + // FOUC prevention completeness + let foucNeeded = 0; + let foucComplete = 0; + for (const ix of interactions) { + if (ix.trigger !== 'viewEnter') continue; + const effects = Array.isArray(ix.effects) + ? ix.effects.map((e) => + isRecord(e) ? resolveEffect(e as Record, globalEffects) : {}, + ) + : []; + const isOnce = effects.every((e) => !('triggerType' in e) || e.triggerType === 'once'); + const sameElement = effects.every((e) => !('key' in e) || e.key === ix.key); + + if (isOnce && sameElement) { + foucNeeded++; + if (htmlMeta && setupMeta) { + const hasInitial = ix.key in (htmlMeta.initials ?? {}); + const hasGenerate = setupMeta.hasGenerate === true; + if (hasInitial && hasGenerate) foucComplete++; + } + } + } + const foucScore = foucNeeded === 0 ? 1 : foucComplete / foucNeeded; + subscores.push({ + dimension: 'foucPrevention', + score: foucScore, + weight: 0.2, + details: `${foucComplete}/${foucNeeded} viewEnter+once interactions have complete FOUC prevention`, + }); + + // Fill usage correctness + let fillChecks = 0; + let fillCorrect = 0; + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const raw of ix.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + const isAnimation = 'keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff; + if (!isAnimation) continue; + + const triggerType = eff.triggerType as string | undefined; + const fill = eff.fill as string | undefined; + + if (triggerType === 'once' || (!triggerType && ix.trigger === 'viewEnter')) { + fillChecks++; + if (fill === 'forwards' || fill === 'both') fillCorrect++; + } else if (triggerType === 'alternate') { + fillChecks++; + if (fill === 'both' || fill === 'forwards') fillCorrect++; + } + } + } + const fillScore = fillChecks === 0 ? 1 : fillCorrect / fillChecks; + subscores.push({ + dimension: 'fillUsage', + score: fillScore, + weight: 0.15, + details: `${fillCorrect}/${fillChecks} effects have correct fill value`, + }); + + // effectId usage for shared effects (prefer effectId over inline duplication) + let effectIdUsage = 0; + let inlineEffects = 0; + const definedEffects = Object.keys(config.effects ?? {}).length; + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const eff of ix.effects) { + if (!isRecord(eff)) continue; + const e = eff as Record; + if (typeof e.effectId === 'string') { + effectIdUsage++; + } else if ( + 'keyframeEffect' in e || + 'namedEffect' in e || + 'customEffect' in e || + 'transition' in e + ) { + inlineEffects++; + } + } + } + const totalEffectRefs = effectIdUsage + inlineEffects; + const reuseScore = + totalEffectRefs === 0 + ? 1 + : definedEffects > 0 + ? Math.min(1, effectIdUsage / totalEffectRefs + 0.3) + : inlineEffects <= 2 + ? 1 + : 0.6; + subscores.push({ + dimension: 'effectReuse', + score: Math.min(1, reuseScore), + weight: 0.2, + details: `${effectIdUsage} effectId refs, ${inlineEffects} inline, ${definedEffects} defined`, + }); + + // Cleanup code (destroy) + let destroyScore = 1; + if (setupMeta) { + destroyScore = setupMeta.hasDestroy ? 1 : 0.3; + } + subscores.push({ + dimension: 'cleanup', + score: destroyScore, + weight: 0.2, + details: setupMeta?.hasDestroy ? 'destroy() present' : setupMeta ? 'No destroy()' : 'Unknown', + }); + + const score = weightedAverage(subscores); + return { + dimension: 'bestPractices', + score, + weight: 0.15, + details: `Best practices score: anti-patterns, FOUC, fill, reuse, cleanup`, + subscores, + }; +} diff --git a/packages/interact-debug/src/score/coherenceScorer.ts b/packages/interact-debug/src/score/coherenceScorer.ts new file mode 100644 index 00000000..a2dc2d53 --- /dev/null +++ b/packages/interact-debug/src/score/coherenceScorer.ts @@ -0,0 +1,122 @@ +import type { InteractConfig, ScoreResult, Scope } from '../types'; +import { isRecord, isInScope, resolveEffect, buildGlobalMaps } from '../validate/helpers'; +import { weightedAverage } from './utils'; + +const ENTRANCE_PATTERN = /In$/; +const SCROLL_PATTERN = /Scroll$/; +const MOUSE_PATTERN = /Mouse$/; +const ONGOING_NAMES = new Set([ + 'Bounce', + 'Breathe', + 'Cross', + 'Flash', + 'Flip', + 'Fold', + 'Jello', + 'Poke', + 'Pulse', + 'Rubber', + 'Spin', + 'Swing', + 'Wiggle', +]); +const BG_SCROLL_PATTERN = /^Bg|^ImageParallax$/; + +const TRIGGER_TO_PRESET_AFFINITY: Record boolean> = { + viewEnter: (t) => ENTRANCE_PATTERN.test(t), + pageVisible: (t) => ENTRANCE_PATTERN.test(t), + viewProgress: (t) => SCROLL_PATTERN.test(t) || BG_SCROLL_PATTERN.test(t), + pointerMove: (t) => MOUSE_PATTERN.test(t), + hover: (t) => ONGOING_NAMES.has(t) || ENTRANCE_PATTERN.test(t), + click: (t) => ONGOING_NAMES.has(t) || ENTRANCE_PATTERN.test(t), + activate: (t) => ONGOING_NAMES.has(t) || ENTRANCE_PATTERN.test(t), + interest: (t) => ONGOING_NAMES.has(t) || ENTRANCE_PATTERN.test(t), + animationEnd: (t) => ENTRANCE_PATTERN.test(t) || ONGOING_NAMES.has(t), +}; + +/** + * Scores semantic alignment between triggers and effects. + * Entrance presets with viewEnter, scroll presets with viewProgress, + * mouse presets with pointerMove, etc. Also checks consistency of + * easing and duration across the config. + */ +export function scoreCoherence(config: InteractConfig, scope?: Scope): ScoreResult { + const { globalEffects } = buildGlobalMaps(config); + const subscores: ScoreResult[] = []; + + const interactions = scope + ? config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : config.interactions; + + // Named effect + trigger affinity + let namedEffectCount = 0; + let alignedCount = 0; + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const raw of ix.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + if (!isRecord(eff.namedEffect)) continue; + const ne = eff.namedEffect as Record; + if (typeof ne.type !== 'string') continue; + + namedEffectCount++; + const checker = TRIGGER_TO_PRESET_AFFINITY[ix.trigger]; + if (checker && checker(ne.type)) { + alignedCount++; + } + } + } + const affinityScore = namedEffectCount === 0 ? 1 : alignedCount / namedEffectCount; + subscores.push({ + dimension: 'presetTriggerAffinity', + score: affinityScore, + weight: 0.5, + details: `${alignedCount}/${namedEffectCount} named effects match their trigger type`, + }); + + // Easing/duration consistency: penalize wildly different values + const durations: number[] = []; + const easings: string[] = []; + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const raw of ix.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + if (typeof eff.duration === 'number') durations.push(eff.duration as number); + if (typeof eff.easing === 'string') easings.push(eff.easing as string); + } + } + + let consistencyScore = 1; + if (durations.length >= 2) { + const maxD = Math.max(...durations); + const minD = Math.min(...durations); + const ratio = maxD > 0 ? minD / maxD : 1; + // A ratio close to 1 means consistent durations + consistencyScore = Math.max(0, ratio); + } + + const uniqueEasings = new Set(easings).size; + if (uniqueEasings > 3) { + consistencyScore *= Math.max(0.5, 1 - (uniqueEasings - 3) * 0.1); + } + + const minDuration = durations.length > 0 ? Math.min(...durations) : 0; + const maxDuration = durations.length > 0 ? Math.max(...durations) : 0; + subscores.push({ + dimension: 'durationEasingConsistency', + score: consistencyScore, + weight: 0.5, + details: `${durations.length} durations (range ${minDuration}–${maxDuration}ms), ${uniqueEasings} unique easings`, + }); + + const score = weightedAverage(subscores); + return { + dimension: 'coherence', + score, + weight: 0.1, + details: `Semantic alignment of triggers to effects and consistency of timing values`, + subscores, + }; +} diff --git a/packages/interact-debug/src/score/complexityScorer.ts b/packages/interact-debug/src/score/complexityScorer.ts new file mode 100644 index 00000000..bf9035fb --- /dev/null +++ b/packages/interact-debug/src/score/complexityScorer.ts @@ -0,0 +1,114 @@ +import type { InteractConfig, ScoreResult, Scope } from '../types'; +import { isRecord, isInScope } from '../validate/helpers'; +import { weightedAverage } from './utils'; + +/** + * Scores config complexity. Lower complexity is better (closer to 1.0). + * Penalizes excessive interactions, effects per interaction, cross-key depth, + * conditions, and nested sequences. + */ +export function scoreComplexity(config: InteractConfig, scope?: Scope): ScoreResult { + const subscores: ScoreResult[] = []; + + const interactions = scope + ? config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : config.interactions; + + // Interaction count: sweet spot 1–10, diminishing above + const ixCount = interactions.length; + const ixScore = ixCount <= 10 ? 1 : Math.max(0, 1 - (ixCount - 10) / 20); + subscores.push({ + dimension: 'interactionCount', + score: ixScore, + weight: 0.25, + details: `${ixCount} interactions`, + }); + + // Effects per interaction: >5 penalized + let totalEffectsPerIx = 0; + let maxEffectsPerIx = 0; + for (const ix of interactions) { + const count = + (Array.isArray(ix.effects) ? ix.effects.length : 0) + + (Array.isArray(ix.sequences) ? ix.sequences.length : 0); + totalEffectsPerIx += count; + maxEffectsPerIx = Math.max(maxEffectsPerIx, count); + } + const avgEffects = interactions.length > 0 ? totalEffectsPerIx / interactions.length : 0; + const effScore = maxEffectsPerIx <= 5 ? 1 : Math.max(0, 1 - (maxEffectsPerIx - 5) / 15); + subscores.push({ + dimension: 'effectsPerInteraction', + score: effScore, + weight: 0.2, + details: `max ${maxEffectsPerIx}, avg ${avgEffects.toFixed(1)}`, + }); + + // Cross-key wiring depth + let crossKeyCount = 0; + for (const ix of interactions) { + if (Array.isArray(ix.effects)) { + for (const eff of ix.effects) { + if ( + isRecord(eff) && + typeof (eff as Record).key === 'string' && + (eff as Record).key !== ix.key + ) { + crossKeyCount++; + } + } + } + } + const crossScore = crossKeyCount <= 3 ? 1 : Math.max(0, 1 - (crossKeyCount - 3) / 10); + subscores.push({ + dimension: 'crossKeyWiring', + score: crossScore, + weight: 0.2, + details: `${crossKeyCount} cross-key effects`, + }); + + // Condition count + const condCount = Object.keys(config.conditions ?? {}).length; + const condScore = condCount <= 5 ? 1 : Math.max(0, 1 - (condCount - 5) / 15); + subscores.push({ + dimension: 'conditions', + score: condScore, + weight: 0.15, + details: `${condCount} conditions`, + }); + + // Sequence complexity + let totalSeqEffects = 0; + let nestedSeqCount = 0; + if (config.sequences) { + for (const seq of Object.values(config.sequences)) { + if (!isRecord(seq)) continue; + const seqObj = seq as Record; + const effects = Array.isArray(seqObj.effects) ? seqObj.effects : []; + totalSeqEffects += effects.length; + for (const eff of effects) { + if (isRecord(eff) && typeof (eff as Record).sequenceId === 'string') { + nestedSeqCount++; + } + } + } + } + const seqScore = + totalSeqEffects <= 10 && nestedSeqCount === 0 + ? 1 + : Math.max(0, 1 - totalSeqEffects / 30 - nestedSeqCount * 0.15); + subscores.push({ + dimension: 'sequences', + score: seqScore, + weight: 0.2, + details: `${totalSeqEffects} total effects in sequences, ${nestedSeqCount} nested`, + }); + + const score = weightedAverage(subscores); + return { + dimension: 'complexity', + score, + weight: 0.1, + details: `Complexity score based on interaction count, effects, cross-key wiring, conditions, sequences`, + subscores, + }; +} diff --git a/packages/interact-debug/src/score/index.ts b/packages/interact-debug/src/score/index.ts new file mode 100644 index 00000000..3a15a822 --- /dev/null +++ b/packages/interact-debug/src/score/index.ts @@ -0,0 +1,7 @@ +export { scoreComplexity } from './complexityScorer'; +export { scoreWeight } from './weightScorer'; +export { scoreA11y } from './a11yScorer'; +export { scoreCoherence } from './coherenceScorer'; +export { scoreBestPractices } from './bestPracticesScorer'; +export { scoreValidation } from './validationScorer'; +export { scoreConfig, scoreArtifact } from './aggregate'; diff --git a/packages/interact-debug/src/score/integrationScorer.ts b/packages/interact-debug/src/score/integrationScorer.ts new file mode 100644 index 00000000..e2a5c95e --- /dev/null +++ b/packages/interact-debug/src/score/integrationScorer.ts @@ -0,0 +1,29 @@ +import type { InteractArtifact, ScoreResult, Scope } from '../types'; +import { validateIntegration } from '../validate/integrationValidator'; + +/** + * Scores the full artifact integration by running integrationValidator + * and converting error/warning counts to a 0–1 score. + * + * Every config key matched in HTML = full marks; missing keys heavily penalized. + * Also accounts for registerEffects coverage and setup order correctness. + */ +export function scoreIntegration(artifact: InteractArtifact, scope?: Scope): ScoreResult { + const result = validateIntegration(artifact, scope); + + const errorCount = result.errors.length; + const warningCount = result.warnings.length; + + // Each error deducts 0.2 (capped), each warning deducts 0.05 + const errorPenalty = Math.min(1, errorCount * 0.2); + const warningPenalty = Math.min(0.3, warningCount * 0.05); + + const score = Math.max(0, 1 - errorPenalty - warningPenalty); + + return { + dimension: 'integration', + score, + weight: 0.15, + details: `${errorCount} errors, ${warningCount} warnings from integration validation`, + }; +} diff --git a/packages/interact-debug/src/score/utils.ts b/packages/interact-debug/src/score/utils.ts new file mode 100644 index 00000000..94fd0698 --- /dev/null +++ b/packages/interact-debug/src/score/utils.ts @@ -0,0 +1,11 @@ +import type { ScoreResult } from '../types'; + +export function weightedAverage(subscores: ScoreResult[]): number { + let totalWeight = 0; + let weightedSum = 0; + for (const s of subscores) { + weightedSum += s.score * s.weight; + totalWeight += s.weight; + } + return totalWeight > 0 ? weightedSum / totalWeight : 1; +} diff --git a/packages/interact-debug/src/score/validationScorer.ts b/packages/interact-debug/src/score/validationScorer.ts new file mode 100644 index 00000000..f955fc54 --- /dev/null +++ b/packages/interact-debug/src/score/validationScorer.ts @@ -0,0 +1,23 @@ +import type { ValidationResult, ScoreResult } from '../types'; + +/** + * Converts the full validateAll result (all validators merged) into a 0-1 score. + * Every validation error and warning penalizes the score, giving real + * differentiation between configs that pass cleanly vs those with issues. + */ +export function scoreValidation(validationResult: ValidationResult): ScoreResult { + const errorCount = validationResult.errors.length; + const warningCount = validationResult.warnings.length; + + const errorPenalty = Math.min(1, errorCount * 0.15); + const warningPenalty = Math.min(0.5, warningCount * 0.05); + + const score = Math.max(0, 1 - errorPenalty - warningPenalty); + + return { + dimension: 'validation', + score, + weight: 0.2, + details: `${errorCount} errors, ${warningCount} warnings from all validators`, + }; +} diff --git a/packages/interact-debug/src/score/weightScorer.ts b/packages/interact-debug/src/score/weightScorer.ts new file mode 100644 index 00000000..50e6fb29 --- /dev/null +++ b/packages/interact-debug/src/score/weightScorer.ts @@ -0,0 +1,168 @@ +import type { InteractConfig, ScoreResult, Scope } from '../types'; +import { isRecord, isInScope, resolveEffect, buildGlobalMaps } from '../validate/helpers'; +import { weightedAverage } from './utils'; + +const COMPOSITE_PROPERTIES = new Set([ + 'transform', + 'opacity', + 'filter', + 'backdrop-filter', + 'clip-path', + 'offset-distance', + 'translate', + 'rotate', + 'scale', +]); + +const LAYOUT_PROPERTIES = new Set([ + 'width', + 'height', + 'top', + 'left', + 'right', + 'bottom', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border-width', + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + 'font-size', + 'line-height', +]); + +/** + * Estimates the rendering cost of animations. Lighter animations (compositor-friendly + * properties, fewer keyframes, shorter total duration) score higher. + */ +export function scoreWeight(config: InteractConfig, scope?: Scope): ScoreResult { + const { globalEffects } = buildGlobalMaps(config); + const subscores: ScoreResult[] = []; + + const interactions = scope + ? config.interactions.filter((ix, i) => isInScope(ix, i, scope)) + : config.interactions; + + let totalKeyframes = 0; + let totalDuration = 0; + let compositorCount = 0; + let layoutCount = 0; + let animationEffectCount = 0; + + for (const ix of interactions) { + if (!Array.isArray(ix.effects)) continue; + for (const raw of ix.effects) { + if (!isRecord(raw)) continue; + const eff = resolveEffect(raw as Record, globalEffects); + const isAnimation = 'keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff; + if (!isAnimation) continue; + + animationEffectCount++; + + if (typeof eff.duration === 'number') { + totalDuration += eff.duration as number; + } + + if (isRecord(eff.keyframeEffect)) { + const kf = eff.keyframeEffect as Record; + if (Array.isArray(kf.keyframes)) { + totalKeyframes += kf.keyframes.length; + const props = extractProperties(kf.keyframes as Record[]); + const { compositor, layout } = classifyProperties(props); + compositorCount += compositor; + layoutCount += layout; + } + } else if (isRecord(eff.namedEffect)) { + totalKeyframes += 2; // assume 2 keyframes for named effects + compositorCount++; // named presets are typically compositor-friendly + } + } + } + + // Keyframe count: ≤50 is ideal + const kfScore = totalKeyframes <= 50 ? 1 : Math.max(0, 1 - (totalKeyframes - 50) / 200); + subscores.push({ + dimension: 'keyframeCount', + score: kfScore, + weight: 0.2, + details: `${totalKeyframes} total keyframes`, + }); + + // Total duration: ≤10s is ideal + const durScore = totalDuration <= 10000 ? 1 : Math.max(0, 1 - (totalDuration - 10000) / 30000); + subscores.push({ + dimension: 'totalDuration', + score: durScore, + weight: 0.2, + details: `${totalDuration}ms total`, + }); + + // Simultaneous animations: based on unique triggers per key + const simultaneousScore = + animationEffectCount <= 10 ? 1 : Math.max(0, 1 - (animationEffectCount - 10) / 20); + subscores.push({ + dimension: 'simultaneousAnimations', + score: simultaneousScore, + weight: 0.2, + details: `${animationEffectCount} animation effects`, + }); + + // Compositor vs layout: prefer compositor-only + const totalProps = compositorCount + layoutCount; + const compositorRatio = totalProps > 0 ? compositorCount / totalProps : 1; + subscores.push({ + dimension: 'compositorFriendly', + score: compositorRatio, + weight: 0.4, + details: `${compositorCount} compositor, ${layoutCount} layout-triggering`, + }); + + const score = weightedAverage(subscores); + return { + dimension: 'weight', + score, + weight: 0.1, + details: `Animation cost estimate based on keyframes, duration, and property types`, + subscores, + }; +} + +function extractProperties(keyframes: Record[]): string[] { + const props = new Set(); + for (const frame of keyframes) { + for (const key of Object.keys(frame)) { + if (key === 'offset' || key === 'easing' || key === 'composite') continue; + props.add(camelToKebab(key)); + } + } + return [...props]; +} + +function classifyProperties(props: string[]): { compositor: number; layout: number } { + let compositor = 0; + let layout = 0; + for (const prop of props) { + if (COMPOSITE_PROPERTIES.has(prop)) { + compositor++; + } else if (LAYOUT_PROPERTIES.has(prop)) { + layout++; + } else { + // Paint-only properties (color, background, etc.) count as compositor-adjacent + compositor++; + } + } + return { compositor, layout }; +} + +function camelToKebab(str: string): string { + return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} diff --git a/packages/interact-debug/test/rulesEval.spec.ts b/packages/interact-debug/test/rulesEval.spec.ts new file mode 100644 index 00000000..a7f11ee9 --- /dev/null +++ b/packages/interact-debug/test/rulesEval.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, afterAll } from 'vitest'; +import { runEvaluation, formatReport, scenarios } from '../src/eval'; +import type { EvalReport } from '../src/eval'; + +const RESULTS_FILE = 'eval-results.json'; + +describe('Rules Evaluation', () => { + let report: EvalReport; + + it( + 'runs all scenarios through LLM generation + scoring', + async () => { + report = await runEvaluation(scenarios, { + onResult: (result) => { + const status = result.success + ? `score=${result.scores?.aggregate.toFixed(2)}, valid=${result.validation?.valid}` + : `FAILED: ${result.error?.slice(0, 80)}`; + console.log(` [${result.scenario.id}] ${status} (${result.durationMs}ms)`); + }, + }); + + console.log('\n' + formatReport(report)); + }, + 20 * 60 * 1000, + ); // 20 minutes total for 8 LLM calls (~2 min each) + + afterAll(async () => { + if (!report) return; + + // Write results JSON for tracking over time + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const outPath = path.resolve(__dirname, '..', RESULTS_FILE); + + // Strip raw LLM output to keep the file manageable + const slimResults = report.results.map((r) => ({ + scenario: r.scenario.id, + success: r.success, + error: r.error, + validation: r.validation + ? { + valid: r.validation.valid, + errors: r.validation.errors.length, + warnings: r.validation.warnings.length, + infos: r.validation.infos.length, + } + : undefined, + scores: r.scores + ? { + aggregate: r.scores.aggregate, + dimensions: r.scores.dimensions.map((d) => ({ + dimension: d.dimension, + score: d.score, + })), + } + : undefined, + durationMs: r.durationMs, + })); + + const output = { + timestamp: report.timestamp, + summary: report.summary, + results: slimResults, + }; + + await fs.writeFile(outPath, JSON.stringify(output, null, 2)); + console.log(`Results written to ${outPath}`); + }); +}); diff --git a/packages/interact-debug/test/score.spec.ts b/packages/interact-debug/test/score.spec.ts new file mode 100644 index 00000000..90e51c3b --- /dev/null +++ b/packages/interact-debug/test/score.spec.ts @@ -0,0 +1,575 @@ +import { describe, it, expect } from 'vitest'; +import { scoreComplexity } from '../src/score/complexityScorer'; +import { scoreWeight } from '../src/score/weightScorer'; +import { scoreA11y } from '../src/score/a11yScorer'; +import { scoreCoherence } from '../src/score/coherenceScorer'; +import { scoreBestPractices } from '../src/score/bestPracticesScorer'; +import { scoreValidation } from '../src/score/validationScorer'; +import { scoreConfig, scoreArtifact } from '../src/score/aggregate'; +import { validateAll } from '../src/validate'; +import type { InteractArtifact, InteractConfig, ValidationResult } from '../src/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function simpleConfig(overrides?: Partial): InteractConfig { + return { + effects: { + fadeIn: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 500, + }, + }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }], + ...overrides, + } as InteractConfig; +} + +function simpleArtifact(overrides?: Partial): InteractArtifact { + return { + config: simpleConfig(), + sourceType: 'separated', + confidence: 'high', + htmlMeta: { keys: ['hero'], initials: { hero: true }, interactElements: [] }, + setupMeta: { + hasGenerate: true, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// scoreComplexity +// --------------------------------------------------------------------------- + +describe('scoreComplexity', () => { + it('scores a simple config near 1.0', () => { + const result = scoreComplexity(simpleConfig()); + expect(result.dimension).toBe('complexity'); + expect(result.score).toBeGreaterThan(0.8); + expect(result.subscores).toBeDefined(); + }); + + it('penalizes many interactions', () => { + const interactions = Array.from({ length: 25 }, (_, i) => ({ + key: `k${i}`, + trigger: 'viewEnter' as const, + effects: [{ effectId: 'fadeIn' }], + })); + const result = scoreComplexity(simpleConfig({ interactions } as any)); + const ixSub = result.subscores!.find((s) => s.dimension === 'interactionCount'); + expect(ixSub!.score).toBeLessThan(1); + }); + + it('penalizes many effects per interaction', () => { + const effects = Array.from({ length: 10 }, (_, i) => ({ + keyframeEffect: { name: `e${i}`, keyframes: [{ opacity: 0 }] }, + duration: 100, + })); + const result = scoreComplexity( + simpleConfig({ + interactions: [{ key: 'hero', trigger: 'viewEnter', effects }], + } as any), + ); + const effSub = result.subscores!.find((s) => s.dimension === 'effectsPerInteraction'); + expect(effSub!.score).toBeLessThan(1); + }); + + it('respects scope filtering', () => { + const config = simpleConfig({ + interactions: [ + { key: 'a', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + { key: 'b', trigger: 'hover', effects: [{ effectId: 'fadeIn' }] }, + ], + } as any); + const full = scoreComplexity(config); + const scoped = scoreComplexity(config, { key: 'a' }); + expect(scoped.subscores!.find((s) => s.dimension === 'interactionCount')!.details).toContain( + '1 interactions', + ); + }); +}); + +// --------------------------------------------------------------------------- +// scoreWeight +// --------------------------------------------------------------------------- + +describe('scoreWeight', () => { + it('scores a simple config near 1.0', () => { + const result = scoreWeight(simpleConfig()); + expect(result.dimension).toBe('weight'); + expect(result.score).toBeGreaterThan(0.8); + }); + + it('penalizes layout-triggering properties', () => { + const config = simpleConfig({ + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { + name: 'resize', + keyframes: [{ width: '100px' }, { width: '200px' }], + }, + duration: 500, + }, + ], + }, + ], + } as any); + const result = scoreWeight(config); + const compositorSub = result.subscores!.find((s) => s.dimension === 'compositorFriendly'); + expect(compositorSub!.score).toBeLessThan(1); + }); + + it('rewards compositor-only properties', () => { + const config = simpleConfig({ + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { + name: 'fadeMove', + keyframes: [ + { opacity: 0, transform: 'translateY(20px)' }, + { opacity: 1, transform: 'translateY(0)' }, + ], + }, + duration: 500, + }, + ], + }, + ], + } as any); + const result = scoreWeight(config); + const compositorSub = result.subscores!.find((s) => s.dimension === 'compositorFriendly'); + expect(compositorSub!.score).toBe(1); + }); + + it('penalizes very long total duration', () => { + const effects = Array.from({ length: 5 }, (_, i) => ({ + keyframeEffect: { name: `e${i}`, keyframes: [{ opacity: 0 }] }, + duration: 5000, + })); + const result = scoreWeight( + simpleConfig({ + interactions: [{ key: 'hero', trigger: 'viewEnter', effects }], + } as any), + ); + const durSub = result.subscores!.find((s) => s.dimension === 'totalDuration'); + expect(durSub!.score).toBeLessThan(1); + }); +}); + +// --------------------------------------------------------------------------- +// scoreA11y +// --------------------------------------------------------------------------- + +describe('scoreA11y', () => { + it('gives full score when activate is used instead of click', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + interactions: [ + { key: 'btn', trigger: 'activate', effects: [{ effectId: 'fadeIn' }] }, + { key: 'card', trigger: 'interest', effects: [{ effectId: 'fadeIn' }] }, + ], + } as any), + htmlMeta: { keys: ['btn', 'card'], initials: {}, interactElements: [] }, + setupMeta: { hasA11yTriggers: true, hasDestroy: true, hasRegisterEffects: false }, + }); + const result = scoreA11y(artifact); + expect(result.dimension).toBe('a11y'); + const activateSub = result.subscores!.find((s) => s.dimension === 'clickActivatePairing'); + expect(activateSub!.score).toBe(1); + const interestSub = result.subscores!.find((s) => s.dimension === 'hoverInterestPairing'); + expect(interestSub!.score).toBe(1); + }); + + it('penalizes click (should use activate instead)', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + interactions: [{ key: 'btn', trigger: 'click', effects: [{ effectId: 'fadeIn' }] }], + } as any), + htmlMeta: { keys: ['btn'], initials: {}, interactElements: [] }, + }); + const result = scoreA11y(artifact); + const activateSub = result.subscores!.find((s) => s.dimension === 'clickActivatePairing'); + expect(activateSub!.score).toBe(0); + }); + + it('gives partial score when both click and activate are used (redundant but not wrong)', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + interactions: [ + { key: 'btn', trigger: 'click', effects: [{ effectId: 'fadeIn' }] }, + { key: 'btn', trigger: 'activate', effects: [{ effectId: 'fadeIn' }] }, + ], + } as any), + htmlMeta: { keys: ['btn'], initials: {}, interactElements: [] }, + setupMeta: { hasA11yTriggers: true, hasDestroy: true, hasRegisterEffects: false }, + }); + const result = scoreA11y(artifact); + const activateSub = result.subscores!.find((s) => s.dimension === 'clickActivatePairing'); + expect(activateSub!.score).toBe(0.5); + }); + + it('rewards prefers-reduced-motion condition', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + conditions: { + reducedMotion: { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, + }, + } as any), + }); + const result = scoreA11y(artifact); + const rmSub = result.subscores!.find((s) => s.dimension === 'reducedMotion'); + expect(rmSub!.score).toBe(1); + }); + + it('penalizes missing prefers-reduced-motion', () => { + const result = scoreA11y(simpleArtifact()); + const rmSub = result.subscores!.find((s) => s.dimension === 'reducedMotion'); + expect(rmSub!.score).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// scoreCoherence +// --------------------------------------------------------------------------- + +describe('scoreCoherence', () => { + it('scores high when entrance preset is on viewEnter', () => { + const config = simpleConfig({ + effects: { entrance: { namedEffect: { type: 'FadeIn' }, duration: 500 } }, + interactions: [{ key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] }], + } as any); + const result = scoreCoherence(config); + const affinitySub = result.subscores!.find((s) => s.dimension === 'presetTriggerAffinity'); + expect(affinitySub!.score).toBe(1); + }); + + it('scores high when scroll preset is on viewProgress', () => { + const config = simpleConfig({ + effects: { + scroll: { + namedEffect: { type: 'FadeScroll' }, + rangeStart: { name: 'entry' }, + rangeEnd: { name: 'cover' }, + }, + }, + interactions: [{ key: 'hero', trigger: 'viewProgress', effects: [{ effectId: 'scroll' }] }], + } as any); + const result = scoreCoherence(config); + const affinitySub = result.subscores!.find((s) => s.dimension === 'presetTriggerAffinity'); + expect(affinitySub!.score).toBe(1); + }); + + it('scores high when mouse preset is on pointerMove', () => { + const config = simpleConfig({ + effects: { mouse: { namedEffect: { type: 'TrackMouse' } } }, + interactions: [{ key: 'hero', trigger: 'pointerMove', effects: [{ effectId: 'mouse' }] }], + } as any); + const result = scoreCoherence(config); + const affinitySub = result.subscores!.find((s) => s.dimension === 'presetTriggerAffinity'); + expect(affinitySub!.score).toBe(1); + }); + + it('penalizes mismatched preset/trigger pairing', () => { + const config = simpleConfig({ + effects: { + entrance: { + namedEffect: { type: 'FadeIn' }, + rangeStart: { name: 'entry' }, + rangeEnd: { name: 'cover' }, + }, + }, + interactions: [{ key: 'hero', trigger: 'viewProgress', effects: [{ effectId: 'entrance' }] }], + } as any); + const result = scoreCoherence(config); + const affinitySub = result.subscores!.find((s) => s.dimension === 'presetTriggerAffinity'); + expect(affinitySub!.score).toBe(0); + }); + + it('penalizes wildly inconsistent durations', () => { + const config = simpleConfig({ + interactions: [ + { + key: 'a', + trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 100 }], + }, + { + key: 'b', + trigger: 'viewEnter', + effects: [ + { keyframeEffect: { name: 'b', keyframes: [{ opacity: 0 }] }, duration: 10000 }, + ], + }, + ], + } as any); + const result = scoreCoherence(config); + const consistSub = result.subscores!.find((s) => s.dimension === 'durationEasingConsistency'); + expect(consistSub!.score).toBeLessThan(0.5); + }); + + it('gives full consistency score for same durations', () => { + const config = simpleConfig({ + interactions: [ + { + key: 'a', + trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{ opacity: 0 }] }, duration: 500 }], + }, + { + key: 'b', + trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'b', keyframes: [{ opacity: 0 }] }, duration: 500 }], + }, + ], + } as any); + const result = scoreCoherence(config); + const consistSub = result.subscores!.find((s) => s.dimension === 'durationEasingConsistency'); + expect(consistSub!.score).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// scoreBestPractices +// --------------------------------------------------------------------------- + +describe('scoreBestPractices', () => { + it('gives high score for well-formed artifact', () => { + const artifact = simpleArtifact(); + const result = scoreBestPractices(artifact); + expect(result.dimension).toBe('bestPractices'); + expect(result.score).toBeGreaterThan(0.5); + }); + + it('penalizes anti-patterns', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + ], + } as any), + }); + const result = scoreBestPractices(artifact); + const antiSub = result.subscores!.find((s) => s.dimension === 'antiPatterns'); + expect(antiSub!.score).toBeLessThan(1); + }); + + it('penalizes missing destroy', () => { + const artifact = simpleArtifact({ + setupMeta: { + hasGenerate: true, + hasDestroy: false, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = scoreBestPractices(artifact); + const cleanupSub = result.subscores!.find((s) => s.dimension === 'cleanup'); + expect(cleanupSub!.score).toBeLessThan(1); + }); + + it('rewards complete FOUC prevention', () => { + const artifact = simpleArtifact({ + htmlMeta: { keys: ['hero'], initials: { hero: true }, interactElements: [] }, + setupMeta: { + hasGenerate: true, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = scoreBestPractices(artifact); + const foucSub = result.subscores!.find((s) => s.dimension === 'foucPrevention'); + expect(foucSub!.score).toBe(1); + }); + + it('penalizes incomplete FOUC (missing generate)', () => { + const artifact = simpleArtifact({ + htmlMeta: { keys: ['hero'], initials: { hero: true }, interactElements: [] }, + setupMeta: { + hasGenerate: false, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = scoreBestPractices(artifact); + const foucSub = result.subscores!.find((s) => s.dimension === 'foucPrevention'); + expect(foucSub!.score).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// scoreValidation +// --------------------------------------------------------------------------- + +describe('scoreValidation', () => { + it('gives full score for zero errors and warnings', () => { + const result = scoreValidation({ valid: true, errors: [], warnings: [], infos: [] }); + expect(result.dimension).toBe('validation'); + expect(result.score).toBe(1); + }); + + it('penalizes errors', () => { + const errors = Array.from({ length: 3 }, (_, i) => ({ + severity: 'error' as const, + message: `err${i}`, + path: [], + rule: 'test', + })); + const result = scoreValidation({ valid: false, errors, warnings: [], infos: [] }); + expect(result.score).toBeLessThan(1); + expect(result.score).toBeCloseTo(1 - 3 * 0.15, 5); + }); + + it('penalizes warnings', () => { + const warnings = Array.from({ length: 4 }, (_, i) => ({ + severity: 'warning' as const, + message: `warn${i}`, + path: [], + rule: 'test', + })); + const result = scoreValidation({ valid: true, errors: [], warnings, infos: [] }); + expect(result.score).toBeCloseTo(1 - 4 * 0.05, 5); + }); + + it('clamps score at 0', () => { + const errors = Array.from({ length: 20 }, (_, i) => ({ + severity: 'error' as const, + message: `err${i}`, + path: [], + rule: 'test', + })); + const result = scoreValidation({ valid: false, errors, warnings: [], infos: [] }); + expect(result.score).toBe(0); + }); + + it('works with validateAll output', () => { + const artifact = simpleArtifact(); + const validation = validateAll(artifact); + const result = scoreValidation(validation); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// scoreConfig (aggregate, config-only) +// --------------------------------------------------------------------------- + +describe('scoreConfig', () => { + it('returns a report with aggregate and dimensions', () => { + const report = scoreConfig(simpleConfig()); + expect(report.aggregate).toBeGreaterThan(0); + expect(report.aggregate).toBeLessThanOrEqual(1); + expect(report.dimensions).toHaveLength(3); + expect(report.dimensions.map((d) => d.dimension)).toEqual([ + 'complexity', + 'weight', + 'coherence', + ]); + }); + + it('aggregate is a weighted average of dimensions', () => { + const report = scoreConfig(simpleConfig()); + let wSum = 0; + let wTotal = 0; + for (const d of report.dimensions) { + wSum += d.score * d.weight; + wTotal += d.weight; + } + expect(report.aggregate).toBeCloseTo(wSum / wTotal, 5); + }); +}); + +// --------------------------------------------------------------------------- +// scoreArtifact (aggregate, full artifact) +// --------------------------------------------------------------------------- + +describe('scoreArtifact', () => { + it('returns a report with 5 dimensions (no validation when not provided)', () => { + const report = scoreArtifact(simpleArtifact()); + expect(report.dimensions).toHaveLength(5); + const dims = report.dimensions.map((d) => d.dimension); + expect(dims).toContain('complexity'); + expect(dims).toContain('weight'); + expect(dims).toContain('a11y'); + expect(dims).toContain('coherence'); + expect(dims).toContain('bestPractices'); + }); + + it('returns 6 dimensions when validationResult is provided', () => { + const artifact = simpleArtifact(); + const validation = validateAll(artifact); + const report = scoreArtifact(artifact, undefined, validation); + expect(report.dimensions).toHaveLength(6); + const dims = report.dimensions.map((d) => d.dimension); + expect(dims).toContain('validation'); + }); + + it('aggregate is between 0 and 1', () => { + const report = scoreArtifact(simpleArtifact()); + expect(report.aggregate).toBeGreaterThan(0); + expect(report.aggregate).toBeLessThanOrEqual(1); + }); + + it('respects scope', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + interactions: [ + { key: 'a', trigger: 'viewEnter', effects: [{ effectId: 'fadeIn' }] }, + { key: 'b', trigger: 'hover', effects: [{ effectId: 'fadeIn' }] }, + ], + } as any), + htmlMeta: { keys: ['a', 'b'], initials: {}, interactElements: [] }, + }); + const scoped = scoreArtifact(artifact, { key: 'a' }); + expect(scoped.aggregate).toBeDefined(); + expect(scoped.dimensions).toHaveLength(5); + }); + + it('well-formed artifact scores high', () => { + const artifact = simpleArtifact({ + config: simpleConfig({ + conditions: { + reducedMotion: { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, + }, + } as any), + }); + const report = scoreArtifact(artifact); + expect(report.aggregate).toBeGreaterThan(0.7); + }); + + it('validation errors lower the aggregate score', () => { + const artifact = simpleArtifact(); + const noVal = scoreArtifact(artifact); + + const mockValidation: ValidationResult = { + valid: false, + errors: Array.from({ length: 5 }, (_, i) => ({ + severity: 'error' as const, + message: `err${i}`, + path: [], + rule: 'test', + })), + warnings: [], + infos: [], + }; + const withVal = scoreArtifact(artifact, undefined, mockValidation); + + expect(withVal.aggregate).toBeLessThan(noVal.aggregate); + }); +}); diff --git a/packages/interact-debug/vitest.eval.config.ts b/packages/interact-debug/vitest.eval.config.ts new file mode 100644 index 00000000..989b0aa3 --- /dev/null +++ b/packages/interact-debug/vitest.eval.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/rulesEval.spec.ts'], + testTimeout: 1_200_000, + }, +}); From 48b985ad1f4f693f0cd78ee5672270fdbd15e8bb Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 29 Apr 2026 13:34:26 +0300 Subject: [PATCH 5/5] wip --- .github/workflows/interact-debug-e2e.yml | 123 +++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/interact-debug-e2e.yml diff --git a/.github/workflows/interact-debug-e2e.yml b/.github/workflows/interact-debug-e2e.yml new file mode 100644 index 00000000..f1ce1d41 --- /dev/null +++ b/.github/workflows/interact-debug-e2e.yml @@ -0,0 +1,123 @@ +name: Interact Debug E2E Tests + +on: + push: + branches: + - master + workflow_dispatch: + inputs: + branch: + type: string + description: Branch to run tests on + default: master + browser: + type: choice + description: Browser to run tests in + options: + - chromium + - firefox + - webkit + - all + default: chromium + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + interact-debug-e2e: + name: Interact Debug E2E (${{ github.event.inputs.browser || 'chromium' }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Cache Yarn dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + .yarn/cache + .yarn/install-state.gz + node_modules + **/node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@4.10.3 --activate + yarn set version 4.10.3 + + - name: Install dependencies + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: NPQ_PKG_MGR=yarn npx npq install --immutable + + - name: Build motion package + run: yarn workspace @wix/motion build + + - name: Build interact package + run: yarn workspace @wix/interact build + + - name: Build interact-debug package + run: yarn workspace @wix/interact-debug build + + - name: Cache Playwright browsers + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ github.event.inputs.browser || 'chromium' }}-${{ hashFiles('packages/interact-debug/package.json') }} + restore-keys: | + ${{ runner.os }}-playwright-${{ github.event.inputs.browser || 'chromium' }}- + ${{ runner.os }}-playwright- + + - name: Install Playwright browsers (all) + if: steps.playwright-cache.outputs.cache-hit != 'true' && (github.event.inputs.browser == 'all' || github.event.inputs.browser == '') + working-directory: packages/interact-debug + run: npx playwright install --with-deps chromium firefox webkit + + - name: Install Playwright browsers (selected) + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event.inputs.browser != 'all' && github.event.inputs.browser != '' + working-directory: packages/interact-debug + run: npx playwright install --with-deps ${{ github.event.inputs.browser }} + + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps ${{ github.event.inputs.browser != 'all' && github.event.inputs.browser != '' && github.event.inputs.browser || '' }} + + - name: Run E2E tests (selected browser) + if: github.event.inputs.browser != 'all' && github.event.inputs.browser != '' + working-directory: packages/interact-debug + run: npx playwright test --project=${{ github.event.inputs.browser }} + + - name: Run E2E tests (all browsers) + if: github.event.inputs.browser == 'all' + working-directory: packages/interact-debug + run: npx playwright test + + - name: Upload Playwright report + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: interact-debug-playwright-report + path: packages/interact-debug/playwright-report/ + retention-days: 14