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 diff --git a/packages/interact-debug/bin/run-eval-compare.ts b/packages/interact-debug/bin/run-eval-compare.ts new file mode 100644 index 00000000..2cfdc6ad --- /dev/null +++ b/packages/interact-debug/bin/run-eval-compare.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env npx tsx +/** + * Runs the evaluation three times with different levels of rules context: + * 1. no-rules: Base prompt only (no @wix/interact documentation) + * 2. partial: Only the core overview rule (full-lean.md) + * 3. full-rules: All 7 rule files (the default) + * + * Writes a comparison JSON and prints a side-by-side table. + */ +import { + runEvaluation, + formatReport, + scenarios, + buildSystemPromptFromFiles, + buildSystemPrompt, +} from '../src/eval'; +import type { EvalReport } from '../src/eval'; +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const BARE_PROMPT = `You are an expert web developer. Generate a complete, single-file HTML document that implements the requested animation scenario. + +Requirements: +- Output ONLY the HTML document. No explanations, no markdown fences, no commentary. +- The HTML must be a complete document (, , , ). +- All CSS goes inside a + + + + +

Motion is Magic

+
+ + + +
+
+ + + + +``` ### 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/index.ts b/packages/interact-debug/src/playwright/index.ts new file mode 100644 index 00000000..691855ac --- /dev/null +++ b/packages/interact-debug/src/playwright/index.ts @@ -0,0 +1,32 @@ +// Fixture server +export { serveArtifact } from './fixtureServer'; + +// Helpers +export { + waitForAnimationState, + getAnimationCount, + getComputedStyleProp, + waitForStyleChange, +} from './animationHelpers'; +export { scrollToKey, scrollBy, scrollToProgress } from './scrollHelpers'; +export { + hoverElement, + unhoverElement, + clickElement, + movePointerWithinElement, +} from './pointerHelpers'; +export { fireTrigger, reverseTrigger } from './triggerHelpers'; + +// Assertions +export { expect } from './assertions'; + +// Runtime verifier +export { verifyAll, verifyInteraction, verifyKey } from './runtimeVerifier'; +export type { VerificationResult, VerificationCheck } from './runtimeVerifier'; + +// Runtime scorers +export { scorePerformance } from './performanceScorer'; +export { scoreAnimationFidelity } from './animationFidelityScorer'; + +// Aggregate runtime scoring +export { scoreRuntime, scoreAll } from './aggregate'; 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/src/types.ts b/packages/interact-debug/src/types.ts new file mode 100644 index 00000000..2097ef16 --- /dev/null +++ b/packages/interact-debug/src/types.ts @@ -0,0 +1,186 @@ +import type { + InteractConfig, + Interaction, + Condition, + SequenceConfig, + TriggerType, + Effect, + EffectRef, + TimeEffect, + ScrubEffect, + StateEffect, + TimeAnimationTriggerType, + ViewEnterParams, + PointerMoveParams, + AnimationEndParams, + TriggerParams, + RangeOffset, +} from '@wix/interact'; + +export type { + InteractConfig, + Interaction, + Condition, + SequenceConfig, + TriggerType, + Effect, + EffectRef, + TimeEffect, + ScrubEffect, + StateEffect, + TimeAnimationTriggerType, + ViewEnterParams, + PointerMoveParams, + AnimationEndParams, + TriggerParams, + RangeOffset, +}; + +// --------------------------------------------------------------------------- +// Artifact +// --------------------------------------------------------------------------- + +export type FrameworkType = 'web' | 'react' | 'vanilla'; + +/** + * Structured HTML metadata extracted from the DOM. + * Reliable when provided directly or extracted via JSDOM/browser APIs. + */ +export type HtmlMetadata = { + /** All data-interact-key values found in HTML */ + keys: string[]; + /** data-interact-initial flag per key */ + initials: Record; + /** instances with key and whether they have a child */ + interactElements: { key: string; hasChild: boolean }[]; +}; + +/** + * Structured JS setup metadata. + * Each field is `undefined` when the check could not be performed + * (e.g. no JS source available, or runtime mode where direct inspection + * is preferred). Validators skip checks for undefined fields. + */ +export type SetupMetadata = { + hasGenerate?: boolean; + hasDestroy?: boolean; + hasA11yTriggers?: boolean; + hasRegisterEffects?: boolean; + /** false if registerEffects is called AFTER Interact.create */ + registerBeforeCreate?: boolean; + /** false if Interact.setup is called AFTER Interact.create */ + setupBeforeCreate?: boolean; +}; + +export type InteractArtifact = { + config: InteractConfig; + sourceType: 'separated' | 'mixed' | 'url' | 'directory' | 'runtime'; + + /** Structured HTML metadata — validators consume this, not raw HTML */ + htmlMeta?: HtmlMetadata; + /** Structured JS setup metadata — validators consume this, not raw JS */ + setupMeta?: SetupMetadata; + registeredEffects?: string[]; + framework?: FrameworkType; + + /** + * 'high' when metadata was provided directly or extracted at runtime; + * 'parsed' when derived from best-effort string parsing. + */ + confidence: 'high' | 'parsed'; + + /** Raw source strings for debugging/display. NOT consumed by validators. */ + raw?: { html?: string; css?: string; js?: string }; +}; + +export type ArtifactInput = + | { + type: 'separated'; + config: InteractConfig; + html?: string; + css?: string; + js?: string; + /** Pre-parsed metadata — takes precedence over raw string parsing */ + htmlMeta?: HtmlMetadata; + setupMeta?: SetupMetadata; + registeredEffects?: string[]; + framework?: FrameworkType; + } + | { type: 'mixed'; source: string } + | { type: 'url'; url: string } + | { type: 'directory'; path: string }; + +// --------------------------------------------------------------------------- +// Scope +// --------------------------------------------------------------------------- + +export type Scope = { + key?: string; + interactionIndex?: number; + effectId?: string; + trigger?: TriggerType; +}; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export type ValidationSeverity = 'error' | 'warning' | 'info'; + +export type ValidationEntry = { + severity: ValidationSeverity; + message: string; + /** JSONPath-style location, e.g. ['interactions', 0, 'effects', 1, 'duration'] */ + path: (string | number)[]; + rule: string; +}; + +export type ValidationResult = { + valid: boolean; + errors: ValidationEntry[]; + warnings: ValidationEntry[]; + infos: ValidationEntry[]; +}; + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +export type ScoreResult = { + dimension: string; + score: number; + weight: number; + details: string; + subscores?: ScoreResult[]; +}; + +export type ScoreReport = { + aggregate: number; + dimensions: ScoreResult[]; +}; + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type LogCategory = + | 'config' + | 'handler' + | 'lifecycle' + | 'dom' + | 'animation' + | 'sequence' + | 'condition'; + +export type LogEntry = { + timestamp: number; + level: LogLevel; + category: LogCategory; + key?: string; + trigger?: TriggerType; + effectId?: string; + message: string; + data?: unknown; +}; diff --git a/packages/interact-debug/src/validate/antiPatterns.ts b/packages/interact-debug/src/validate/antiPatterns.ts new file mode 100644 index 00000000..392a7efa --- /dev/null +++ b/packages/interact-debug/src/validate/antiPatterns.ts @@ -0,0 +1,178 @@ +import type { InteractArtifact, ValidationResult, Scope, TriggerType } from '../types'; +import { isRecord, warning, toResult, isInScope, resolveEffect, buildGlobalMaps } from './helpers'; + +const LAYOUT_PROPERTIES = /\b(width|height|top|left|right|bottom|margin|padding)\b/i; +const SIZE_TRANSFORMS = /\b(scale|translate)\b/i; + +const RANGE_ORDER: Record = { + entry: 0, + 'entry-crossing': 1, + contain: 2, + 'exit-crossing': 3, + exit: 4, + cover: 5, +}; + +function effectChangesLayout(eff: Record): boolean { + if (isRecord(eff.keyframeEffect)) { + const kf = eff.keyframeEffect as Record; + if (Array.isArray(kf.keyframes)) { + const str = JSON.stringify(kf.keyframes); + return LAYOUT_PROPERTIES.test(str) || SIZE_TRANSFORMS.test(str); + } + } + return false; +} + +function isSameElementTarget( + interaction: Record, + eff: Record, +): boolean { + if ('selector' in eff) return false; + if (typeof eff.key === 'string' && eff.key !== interaction.key) return false; + return true; +} + +/** + * Detect common anti-patterns from the Interact rules documentation. + */ +export function detectAntiPatterns(artifact: InteractArtifact, scope?: Scope): ValidationResult { + const entries = []; + const { config } = artifact; + const { globalEffects } = buildGlobalMaps(config); + + const keyTriggerPairs = new Set(); + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i] as Record; + if (!isInScope(interaction as { key: string; trigger: string }, i, scope)) continue; + const basePath = ['interactions', i] as (string | number)[]; + const trigger = interaction.trigger as TriggerType; + const key = interaction.key as string; + + const pair = `${key}::${trigger}`; + if (keyTriggerPairs.has(pair)) { + entries.push( + warning( + basePath, + 'duplicate-key-trigger', + `Duplicate key+trigger combination: "${key}" + "${trigger}"; the second interaction may shadow the first`, + ), + ); + } + keyTriggerPairs.add(pair); + + const resolvedEffects = getResolvedEffects(interaction, globalEffects); + + for (let j = 0; j < resolvedEffects.length; j++) { + const eff = resolvedEffects[j]; + const effPath = [...basePath, 'effects', j]; + + if (trigger === 'viewEnter') { + const triggerType = eff.triggerType as string | undefined; + if (triggerType && triggerType !== 'once' && isSameElementTarget(interaction, eff)) { + entries.push( + warning( + effPath, + 'viewEnter-same-element-non-once', + `viewEnter with triggerType "${triggerType}" on same source/target element; use separate source and target elements`, + ), + ); + } + } + + if ( + trigger === 'hover' && + isSameElementTarget(interaction, eff) && + effectChangesLayout(eff) + ) { + entries.push( + warning( + effPath, + 'hover-layout-same-element', + 'hover effect changes size/position on same element as source; this causes hit-area jitter. Use selector to animate a child element', + ), + ); + } + + if ( + trigger === 'pointerMove' && + isSameElementTarget(interaction, eff) && + effectChangesLayout(eff) + ) { + const params = interaction.params as Record | undefined; + if (params?.hitArea === 'self') { + entries.push( + warning( + effPath, + 'pointerMove-self-layout', + 'pointerMove with hitArea "self" and layout-changing effects on same element; use selector to animate a child', + ), + ); + } + } + + if (typeof eff.duration === 'number') { + if (eff.duration === 0) { + entries.push( + warning( + [...effPath, 'duration'], + 'duration-zero', + 'duration is 0, which produces no visible animation', + ), + ); + } else if (eff.duration > 10000) { + entries.push( + warning( + [...effPath, 'duration'], + 'duration-extreme', + `duration of ${eff.duration}ms is very long (>10s); this may be unintentional`, + ), + ); + } + } + + if (isRecord(eff.rangeStart) && isRecord(eff.rangeEnd)) { + const startName = (eff.rangeStart as Record).name as string | undefined; + const endName = (eff.rangeEnd as Record).name as string | undefined; + if (startName && endName && startName in RANGE_ORDER && endName in RANGE_ORDER) { + if (RANGE_ORDER[startName] > RANGE_ORDER[endName]) { + entries.push( + warning( + effPath, + 'range-inverted', + `rangeStart "${startName}" comes after rangeEnd "${endName}" in scroll order; ranges may be inverted`, + ), + ); + } + } + } + } + + if (trigger === 'viewEnter') { + const hasRepeat = resolvedEffects.some((e) => e.triggerType === 'repeat'); + const params = interaction.params as Record | undefined; + if (hasRepeat && (!params || !('threshold' in params))) { + entries.push( + warning( + basePath, + 'viewEnter-repeat-no-threshold', + 'viewEnter with repeat triggerType but no explicit threshold param; consider setting threshold for predictable re-triggering', + ), + ); + } + } + } + + return toResult(entries); +} + +function getResolvedEffects( + interaction: Record, + globalEffects: Record>, +): Record[] { + if (!Array.isArray(interaction.effects)) return []; + return interaction.effects + .filter((e: unknown) => isRecord(e)) + .map((e: unknown) => resolveEffect(e as Record, globalEffects)); +} diff --git a/packages/interact-debug/src/validate/compatibilityValidator.ts b/packages/interact-debug/src/validate/compatibilityValidator.ts new file mode 100644 index 00000000..e9b8252c --- /dev/null +++ b/packages/interact-debug/src/validate/compatibilityValidator.ts @@ -0,0 +1,306 @@ +import type { + InteractConfig, + ValidationResult, + ValidationEntry, + Scope, + TriggerType, +} from '../types'; +import { + TIME_TRIGGERS, + SCRUB_TRIGGERS, + STATE_TRIGGERS, + isRecord, + error, + warning, + toResult, + isInScope, + resolveEffect, + resolveSequence, + buildGlobalMaps, +} from './helpers'; + +// --------------------------------------------------------------------------- +// Effect classification +// --------------------------------------------------------------------------- + +type EffectKind = 'time' | 'scrub' | 'state' | 'unknown'; + +function classifyEffect(eff: Record): EffectKind { + const hasAnimation = 'keyframeEffect' in eff || 'namedEffect' in eff || 'customEffect' in eff; + const hasState = 'transition' in eff || 'transitionProperties' in eff; + + if (hasAnimation && 'duration' in eff && !('rangeStart' in eff) && !('rangeEnd' in eff)) + return 'time'; + if (hasAnimation && ('rangeStart' in eff || 'rangeEnd' in eff)) return 'scrub'; + if (hasState && !hasAnimation) return 'state'; + if (hasAnimation) return 'time'; + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Validate semantic compatibility between triggers and effects: + * - Time effects only on time triggers, scrub on scrub, state on state + * - Property affinity warnings (transitionEasing on non-scrub, stateAction on non-state, etc.) + * - Duration on scrub warning, duration required for time + * - triggerType + stateAction exclusivity + * - triggerType:'state' mismatch with non-state effect + * - triggerType on sequence effects warning + * - animationEnd params.effectId should reference time effect + * - viewProgress rangeStart/rangeEnd warnings + * - namedEffect scroll preset + viewProgress range warning + */ +export function validateCompatibility(config: InteractConfig, scope?: Scope): ValidationResult { + const entries: ValidationEntry[] = []; + const { globalEffects, globalSequences } = buildGlobalMaps(config); + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + if (!isInScope(interaction, i, scope)) continue; + const basePath = ['interactions', i] as (string | number)[]; + const trigger = interaction.trigger as TriggerType; + + if (interaction.effects) { + for (let j = 0; j < interaction.effects.length; j++) { + const raw = interaction.effects[j] as Record; + if (!isRecord(raw)) continue; + const resolved = resolveEffect(raw, globalEffects); + validateEffectCompat(resolved, [...basePath, 'effects', j], trigger, entries); + } + } + + if (interaction.sequences) { + for (let j = 0; j < interaction.sequences.length; j++) { + const raw = interaction.sequences[j] as Record; + if (!isRecord(raw)) continue; + const seqPath = [...basePath, 'sequences', j]; + const resolved = resolveSequence(raw, globalSequences); + const seqEffects = Array.isArray(resolved.effects) ? resolved.effects : []; + + for (let k = 0; k < seqEffects.length; k++) { + const effRaw = seqEffects[k] as Record; + if (!isRecord(effRaw)) continue; + const effResolved = resolveEffect(effRaw, globalEffects); + + if ('triggerType' in effResolved) { + entries.push( + warning( + [...seqPath, 'effects', k, 'triggerType'], + 'sequence-effect-triggerType', + 'triggerType should be on the sequence, not on individual effects inside a sequence', + ), + ); + } + + validateEffectCompat(effResolved, [...seqPath, 'effects', k], trigger, entries); + } + } + } + + // animationEnd: params.effectId should reference a time-based effect + if (trigger === 'animationEnd' && isRecord(interaction.params)) { + const params = interaction.params as Record; + if (typeof params.effectId === 'string' && params.effectId in globalEffects) { + const referenced = globalEffects[params.effectId]; + const kind = classifyEffect(referenced); + if (kind !== 'time' && kind !== 'unknown') { + entries.push( + warning( + [...basePath, 'params', 'effectId'], + 'animationEnd-non-time-effect', + `animationEnd params.effectId "${params.effectId}" references a ${kind} effect; it should reference a time-based effect`, + ), + ); + } + } + } + } + + return toResult(entries); +} + +// --------------------------------------------------------------------------- +// Per-effect compatibility +// --------------------------------------------------------------------------- + +function validateEffectCompat( + eff: Record, + path: (string | number)[], + trigger: TriggerType, + entries: ValidationEntry[], +): void { + const kind = classifyEffect(eff); + const isAnimationEffect = kind === 'time' || kind === 'scrub'; + const isStateEffect = kind === 'state'; + const isScrub = kind === 'scrub'; + const isTime = kind === 'time'; + + // -- Trigger-effect type mismatch -- + if (kind !== 'unknown') { + if (isTime && !TIME_TRIGGERS.has(trigger)) { + entries.push( + error( + path, + 'time-on-non-time-trigger', + `Time effect (has duration) is not supported on "${trigger}" trigger`, + ), + ); + } + if (isScrub && !SCRUB_TRIGGERS.has(trigger)) { + entries.push( + error( + path, + 'scrub-on-non-scrub-trigger', + `Scrub effect (has rangeStart/rangeEnd) is not supported on "${trigger}" trigger`, + ), + ); + } + if (isStateEffect && !STATE_TRIGGERS.has(trigger)) { + entries.push( + error( + path, + 'state-on-non-state-trigger', + `State effect (transition/transitionProperties) is not supported on "${trigger}" trigger`, + ), + ); + } + } + + // -- triggerType:'state' should pair with a state effect -- + if (eff.triggerType === 'state' && !isStateEffect) { + entries.push( + warning( + path, + 'triggerType-state-mismatch', + 'triggerType "state" is typically used with state effects (transition/transitionProperties)', + ), + ); + } + + // -- Do not mix triggerType and stateAction -- + if ('triggerType' in eff && 'stateAction' in eff) { + entries.push( + error( + path, + 'triggerType-stateAction-mixed', + 'An effect cannot have both triggerType and stateAction; use one or the other', + ), + ); + } + + // -- Property affinity warnings -- + if ('stateAction' in eff && !isStateEffect) { + entries.push( + warning( + [...path, 'stateAction'], + 'stateAction-affinity', + 'stateAction is only meaningful on state effects (transition/transitionProperties)', + ), + ); + } + if ('triggerType' in eff && !isTime) { + if (isScrub) { + entries.push( + warning( + [...path, 'triggerType'], + 'triggerType-affinity', + 'triggerType is not used on scrub effects', + ), + ); + } else if (isStateEffect) { + entries.push( + warning( + [...path, 'triggerType'], + 'triggerType-affinity', + 'triggerType is not used on state effects', + ), + ); + } + } + if ('transitionEasing' in eff && !isScrub) { + entries.push( + warning( + [...path, 'transitionEasing'], + 'transitionEasing-affinity', + 'transitionEasing is only meaningful on scrub effects (viewProgress/pointerMove)', + ), + ); + } + if (('rangeStart' in eff || 'rangeEnd' in eff) && !isScrub) { + entries.push( + warning( + path, + 'range-on-non-scrub', + 'rangeStart/rangeEnd are only meaningful on viewProgress/pointerMove triggers', + ), + ); + } + + // -- Time-specific: duration required -- + if (isAnimationEffect && TIME_TRIGGERS.has(trigger) && !SCRUB_TRIGGERS.has(trigger)) { + if (!('duration' in eff) && !('rangeStart' in eff)) { + entries.push( + error( + [...path, 'duration'], + 'duration-required', + 'TimeEffect requires a duration property', + ), + ); + } + } + + // -- Scrub-specific warnings -- + if (isScrub || SCRUB_TRIGGERS.has(trigger)) { + if ('duration' in eff && isAnimationEffect) { + entries.push( + warning( + [...path, 'duration'], + 'duration-on-scrub', + 'duration is not used on scrub effects; scroll/pointer progress drives the animation', + ), + ); + } + if (trigger === 'viewProgress') { + if (!('rangeStart' in eff) && isAnimationEffect) { + entries.push( + warning( + [...path, 'rangeStart'], + 'range-start-missing', + 'viewProgress effects should specify rangeStart', + ), + ); + } + if (!('rangeEnd' in eff) && isAnimationEffect) { + entries.push( + warning( + [...path, 'rangeEnd'], + 'range-end-missing', + 'viewProgress effects should specify rangeEnd', + ), + ); + } + } + } + + // -- namedEffect scroll preset + viewProgress range -- + if ('namedEffect' in eff && isRecord(eff.namedEffect)) { + const ne = eff.namedEffect as Record; + if ( + trigger === 'viewProgress' && + typeof ne.type === 'string' && + ne.type.endsWith('Scroll') && + !('range' in ne) + ) { + entries.push( + warning( + [...path, 'namedEffect', 'range'], + 'named-scroll-range', + `Scroll preset "${ne.type}" used with viewProgress should include range: 'in' | 'out' | 'continuous'`, + ), + ); + } + } +} diff --git a/packages/interact-debug/src/validate/configValidator.ts b/packages/interact-debug/src/validate/configValidator.ts new file mode 100644 index 00000000..04e083f4 --- /dev/null +++ b/packages/interact-debug/src/validate/configValidator.ts @@ -0,0 +1,651 @@ +import type { ValidationResult, ValidationEntry, Scope, TriggerType } from '../types'; +import { + TRIGGER_TYPES, + TRIGGER_TYPE_VALUES, + FILL_VALUES, + CONDITION_TYPES, + RANGE_NAMES, + STATE_ACTIONS, + SCRUB_TRANSITION_EASINGS, + TRIGGERS_REQUIRING_PARAMS, + isRecord, + error, + warning, + toResult, + isInScope, + resolveEffect, + resolveSequence, +} from './helpers'; + +/** + * Validate config schema shape against the InteractConfig type definitions. + * + * This validator checks **structural correctness**: types, required fields, + * enum values, and mutual exclusivity. It does NOT check cross-references + * (effectId/sequenceId/conditions existence) or trigger-effect compatibility — + * those are handled by referenceValidator and compatibilityValidator. + */ +export function validateSchema(config: unknown, scope?: Scope): ValidationResult { + const entries: ValidationEntry[] = []; + + if (!isRecord(config)) { + entries.push(error(['config'], 'config-type', 'Config must be an object')); + return toResult(entries); + } + + // -- effects: must be a plain-object record -- + const effectsValid = isRecord(config.effects); + if (!effectsValid) { + const rule = Array.isArray(config.effects) ? 'effects-not-array' : 'effects-type'; + entries.push( + error(['effects'], rule, 'effects must be a Record (object, not array)'), + ); + } else { + for (const [id, effect] of Object.entries(config.effects as Record)) { + if (!isRecord(effect)) { + entries.push(error(['effects', id], 'effect-type', `Effect "${id}" must be an object`)); + } + } + } + + const globalEffects = effectsValid + ? (config.effects as Record>) + : {}; + + // -- conditions (optional): validate definition shapes -- + if (config.conditions !== undefined) { + if (!isRecord(config.conditions)) { + entries.push( + error(['conditions'], 'conditions-type', 'conditions must be a Record'), + ); + } else { + validateConditionDefinitions(config.conditions as Record, entries); + } + } + + // -- sequences (optional): basic structural check -- + let globalSequences: Record> = {}; + if (config.sequences !== undefined) { + if (!isRecord(config.sequences)) { + entries.push( + error( + ['sequences'], + 'sequences-type', + 'sequences must be a Record', + ), + ); + } else { + globalSequences = config.sequences as Record>; + for (const [id, seq] of Object.entries(globalSequences)) { + if (!isRecord(seq)) { + entries.push( + error(['sequences', id], 'sequence-type', `Sequence "${id}" must be an object`), + ); + } + } + } + } + + // -- interactions: must be a non-empty array -- + if (!Array.isArray(config.interactions)) { + entries.push( + error(['interactions'], 'interactions-type', 'interactions must be a non-empty array'), + ); + } else if (config.interactions.length === 0) { + entries.push( + error(['interactions'], 'interactions-empty', 'interactions array must not be empty'), + ); + } else { + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i] as unknown; + if (!isRecord(interaction)) { + entries.push( + error( + ['interactions', i], + 'interaction-type', + `Interaction at index ${i} must be an object`, + ), + ); + continue; + } + if (!isInScope(interaction as { key: string; trigger: string }, i, scope)) continue; + validateInteraction(interaction, i, globalEffects, globalSequences, entries); + } + } + + return toResult(entries); +} + +// --------------------------------------------------------------------------- +// Interaction +// --------------------------------------------------------------------------- + +function validateInteraction( + interaction: Record, + index: number, + globalEffects: Record>, + globalSequences: Record>, + entries: ValidationEntry[], +): void { + const basePath: (string | number)[] = ['interactions', index]; + const trigger = interaction.trigger as TriggerType; + const validTrigger = TRIGGER_TYPES.includes(trigger); + + if (typeof interaction.key !== 'string' || interaction.key.length === 0) { + entries.push( + error( + [...basePath, 'key'], + 'interaction-key', + 'Interaction must have a non-empty string "key"', + ), + ); + } + + if (!validTrigger) { + entries.push( + error( + [...basePath, 'trigger'], + 'interaction-trigger', + `Interaction trigger must be one of: ${TRIGGER_TYPES.join(', ')}. Got: "${String(interaction.trigger)}"`, + ), + ); + } + + if (interaction.params !== undefined) { + validateParams(interaction.params, trigger, basePath, entries); + } else if (validTrigger && TRIGGERS_REQUIRING_PARAMS.has(trigger)) { + entries.push( + error([...basePath, 'params'], 'params-required', `Trigger "${trigger}" requires params`), + ); + } + + // Conditions shape check (array of strings) — existence is checked by referenceValidator + if (interaction.conditions !== undefined) { + validateConditionsShape(interaction.conditions, [...basePath, 'conditions'], entries); + } + + const hasEffects = interaction.effects !== undefined; + const hasSequences = interaction.sequences !== undefined; + + if (!hasEffects && !hasSequences) { + entries.push( + warning(basePath, 'interaction-no-effects', 'Interaction has neither effects nor sequences'), + ); + } + + if (hasEffects) { + if (!Array.isArray(interaction.effects)) { + entries.push( + error( + [...basePath, 'effects'], + 'interaction-effects-type', + 'Interaction effects must be an array', + ), + ); + } else { + for (let j = 0; j < interaction.effects.length; j++) { + const raw = interaction.effects[j] as unknown; + if (!isRecord(raw)) { + entries.push( + error( + [...basePath, 'effects', j], + 'effect-type', + `Effect at index ${j} must be an object`, + ), + ); + continue; + } + const resolved = resolveEffect(raw, globalEffects); + validateEffectShape(resolved, [...basePath, 'effects', j], entries); + } + } + } + + if (hasSequences) { + if (!Array.isArray(interaction.sequences)) { + entries.push( + error( + [...basePath, 'sequences'], + 'interaction-sequences-type', + 'Interaction sequences must be an array', + ), + ); + } else { + for (let j = 0; j < interaction.sequences.length; j++) { + const raw = interaction.sequences[j] as unknown; + if (!isRecord(raw)) { + entries.push( + error( + [...basePath, 'sequences', j], + 'sequence-type', + `Sequence at index ${j} must be an object`, + ), + ); + continue; + } + validateSequenceShape( + raw, + [...basePath, 'sequences', j], + globalEffects, + globalSequences, + entries, + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Effect shape validation (no compat, no ref checks) +// --------------------------------------------------------------------------- + +function validateEffectShape( + eff: Record, + path: (string | number)[], + entries: ValidationEntry[], +): void { + const hasKeyframe = 'keyframeEffect' in eff; + const hasNamed = 'namedEffect' in eff; + const hasCustom = 'customEffect' in eff; + const hasTransition = 'transition' in eff; + const hasTransitionProps = 'transitionProperties' in eff; + + const animationPropertyCount = (hasKeyframe ? 1 : 0) + (hasNamed ? 1 : 0) + (hasCustom ? 1 : 0); + const isAnimationEffect = animationPropertyCount > 0; + const isStateEffect = hasTransition || hasTransitionProps; + + if (animationPropertyCount > 1) { + entries.push( + error( + path, + 'effect-property-exclusive', + 'Effect must have only one of: keyframeEffect, namedEffect, customEffect', + ), + ); + } + + if (isAnimationEffect && isStateEffect) { + entries.push( + error( + path, + 'effect-mixed-types', + 'Effect cannot mix keyframeEffect/namedEffect/customEffect with transition/transitionProperties', + ), + ); + } + + if (hasTransition && hasTransitionProps) { + entries.push( + error( + path, + 'state-exclusive', + 'Effect cannot have both transition and transitionProperties; use one or the other', + ), + ); + } + + if (!isAnimationEffect && !isStateEffect) { + entries.push( + error( + path, + 'effect-property', + 'Resolved effect must have exactly one of: keyframeEffect, namedEffect, customEffect (for time/scrub) or transition/transitionProperties (for state)', + ), + ); + return; + } + + if (hasKeyframe) validateKeyframeEffect(eff.keyframeEffect, path, entries); + if (hasNamed) validateNamedEffect(eff.namedEffect, path, entries); + + // Validate enum fields regardless of trigger context + if ('duration' in eff && (typeof eff.duration !== 'number' || (eff.duration as number) <= 0)) { + entries.push( + error([...path, 'duration'], 'duration-positive', 'duration must be a positive number'), + ); + } + + if ('triggerType' in eff) { + if (!TRIGGER_TYPE_VALUES.includes(eff.triggerType as (typeof TRIGGER_TYPE_VALUES)[number])) { + entries.push( + error( + [...path, 'triggerType'], + 'trigger-type-value', + `triggerType must be one of: ${TRIGGER_TYPE_VALUES.join(', ')}. Got: "${String(eff.triggerType)}"`, + ), + ); + } + } + + if ('fill' in eff) { + if (!FILL_VALUES.includes(eff.fill as (typeof FILL_VALUES)[number])) { + entries.push( + error( + [...path, 'fill'], + 'fill-value', + `fill must be one of: ${FILL_VALUES.join(', ')}. Got: "${String(eff.fill)}"`, + ), + ); + } + } + + if ('stateAction' in eff) { + if (!STATE_ACTIONS.includes(eff.stateAction as (typeof STATE_ACTIONS)[number])) { + entries.push( + error( + [...path, 'stateAction'], + 'state-action-value', + `stateAction must be one of: ${STATE_ACTIONS.join(', ')}. Got: "${String(eff.stateAction)}"`, + ), + ); + } + } + + if ('transitionEasing' in eff) { + if ( + !SCRUB_TRANSITION_EASINGS.includes( + eff.transitionEasing as (typeof SCRUB_TRANSITION_EASINGS)[number], + ) + ) { + entries.push( + error( + [...path, 'transitionEasing'], + 'scrub-transition-easing', + `transitionEasing must be one of: ${SCRUB_TRANSITION_EASINGS.join(', ')}. Got: "${String(eff.transitionEasing)}"`, + ), + ); + } + } + + // Range offset structure + validateRangeOffset(eff, 'rangeStart', path, entries); + validateRangeOffset(eff, 'rangeEnd', path, entries); + + // Effect-level conditions shape + if (eff.conditions !== undefined) { + validateConditionsShape(eff.conditions, [...path, 'conditions'], entries); + } +} + +// --------------------------------------------------------------------------- +// Sub-property validators +// --------------------------------------------------------------------------- + +function validateKeyframeEffect( + kf: unknown, + path: (string | number)[], + entries: ValidationEntry[], +): void { + if (!isRecord(kf)) { + entries.push( + error([...path, 'keyframeEffect'], 'keyframe-type', 'keyframeEffect must be an object'), + ); + return; + } + if (typeof kf.name !== 'string') { + entries.push( + error( + [...path, 'keyframeEffect', 'name'], + 'keyframe-name', + 'keyframeEffect.name must be a string', + ), + ); + } else if (kf.name.length === 0) { + entries.push( + error( + [...path, 'keyframeEffect', 'name'], + 'keyframe-name-empty', + 'keyframeEffect.name must not be empty', + ), + ); + } + if (!Array.isArray(kf.keyframes) || kf.keyframes.length === 0) { + entries.push( + error( + [...path, 'keyframeEffect', 'keyframes'], + 'keyframe-keyframes', + 'keyframeEffect.keyframes must be a non-empty array', + ), + ); + } +} + +function validateNamedEffect( + ne: unknown, + path: (string | number)[], + entries: ValidationEntry[], +): void { + if (!isRecord(ne)) { + entries.push(error([...path, 'namedEffect'], 'named-type', 'namedEffect must be an object')); + return; + } + if (typeof ne.type !== 'string') { + entries.push( + error( + [...path, 'namedEffect', 'type'], + 'named-effect-type', + 'namedEffect.type must be a string', + ), + ); + } +} + +function validateRangeOffset( + eff: Record, + field: 'rangeStart' | 'rangeEnd', + path: (string | number)[], + entries: ValidationEntry[], +): void { + if (!(field in eff)) return; + const range = eff[field]; + if (!isRecord(range)) { + entries.push(error([...path, field], 'range-type', `${field} must be an object`)); + return; + } + if ('name' in range) { + if (!RANGE_NAMES.includes(range.name as (typeof RANGE_NAMES)[number])) { + entries.push( + error( + [...path, field, 'name'], + 'range-name-value', + `${field}.name must be one of: ${RANGE_NAMES.join(', ')}. Got: "${String(range.name)}"`, + ), + ); + } + } + if ('offset' in range) { + if (!isRecord(range.offset)) { + entries.push( + error( + [...path, field, 'offset'], + 'range-offset-type', + `${field}.offset must be an object with { value, unit }`, + ), + ); + } else { + if (typeof range.offset.value !== 'number') { + entries.push( + error( + [...path, field, 'offset', 'value'], + 'range-offset-value', + `${field}.offset.value must be a number`, + ), + ); + } + if (typeof range.offset.unit !== 'string') { + entries.push( + error( + [...path, field, 'offset', 'unit'], + 'range-offset-unit', + `${field}.offset.unit must be a string (e.g. 'percentage', 'px')`, + ), + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +function validateParams( + params: unknown, + trigger: TriggerType, + basePath: (string | number)[], + entries: ValidationEntry[], +): void { + if (!isRecord(params)) { + entries.push(error([...basePath, 'params'], 'params-type', 'params must be an object')); + return; + } + if (trigger === 'viewEnter' || trigger === 'pageVisible') { + if ('threshold' in params && typeof params.threshold !== 'number') { + entries.push( + error( + [...basePath, 'params', 'threshold'], + 'param-threshold', + 'threshold must be a number', + ), + ); + } + if ('inset' in params && typeof params.inset !== 'string') { + entries.push( + error([...basePath, 'params', 'inset'], 'param-inset', 'inset must be a string'), + ); + } + } + if (trigger === 'pointerMove') { + if ('hitArea' in params && params.hitArea !== 'root' && params.hitArea !== 'self') { + entries.push( + error( + [...basePath, 'params', 'hitArea'], + 'param-hit-area', + 'hitArea must be "root" or "self"', + ), + ); + } + if ('axis' in params && params.axis !== 'x' && params.axis !== 'y') { + entries.push(error([...basePath, 'params', 'axis'], 'param-axis', 'axis must be "x" or "y"')); + } + } + if (trigger === 'animationEnd') { + if (typeof params.effectId !== 'string' || params.effectId.length === 0) { + entries.push( + error( + [...basePath, 'params', 'effectId'], + 'param-effect-id-required', + 'animationEnd trigger requires params.effectId (non-empty string)', + ), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Conditions shape (not ref checking — that's referenceValidator) +// --------------------------------------------------------------------------- + +function validateConditionsShape( + conditions: unknown, + path: (string | number)[], + entries: ValidationEntry[], +): void { + if (!Array.isArray(conditions)) { + entries.push(error(path, 'conditions-array', 'conditions must be an array of strings')); + return; + } + for (let i = 0; i < conditions.length; i++) { + if (typeof conditions[i] !== 'string') { + entries.push( + error([...path, i], 'condition-ref-type', 'Condition reference must be a string'), + ); + } + } +} + +function validateConditionDefinitions( + conditions: Record, + entries: ValidationEntry[], +): void { + for (const [id, cond] of Object.entries(conditions)) { + if (!isRecord(cond)) { + entries.push( + error(['conditions', id], 'condition-type', `Condition "${id}" must be an object`), + ); + continue; + } + if (!CONDITION_TYPES.includes(cond.type as (typeof CONDITION_TYPES)[number])) { + entries.push( + error( + ['conditions', id, 'type'], + 'condition-type-value', + `Condition type must be one of: ${CONDITION_TYPES.join(', ')}. Got: "${String(cond.type)}"`, + ), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Sequences shape +// --------------------------------------------------------------------------- + +function validateSequenceShape( + raw: Record, + path: (string | number)[], + globalEffects: Record>, + globalSequences: Record>, + entries: ValidationEntry[], +): void { + const resolved = resolveSequence(raw, globalSequences); + + if ('delay' in resolved && typeof resolved.delay !== 'number') { + entries.push(error([...path, 'delay'], 'sequence-delay', 'Sequence delay must be a number')); + } + if ('offset' in resolved && typeof resolved.offset !== 'number') { + entries.push(error([...path, 'offset'], 'sequence-offset', 'Sequence offset must be a number')); + } + if ('triggerType' in resolved) { + if ( + !TRIGGER_TYPE_VALUES.includes(resolved.triggerType as (typeof TRIGGER_TYPE_VALUES)[number]) + ) { + entries.push( + error( + [...path, 'triggerType'], + 'trigger-type-value', + `triggerType must be one of: ${TRIGGER_TYPE_VALUES.join(', ')}. Got: "${String(resolved.triggerType)}"`, + ), + ); + } + } + + if (resolved.conditions !== undefined) { + validateConditionsShape(resolved.conditions, [...path, 'conditions'], entries); + } + + if (!Array.isArray(resolved.effects)) { + if (typeof raw.sequenceId !== 'string') { + entries.push( + error([...path, 'effects'], 'sequence-effects-type', 'Sequence must have an effects array'), + ); + } + return; + } + + for (let k = 0; k < resolved.effects.length; k++) { + const rawEff = resolved.effects[k] as unknown; + if (!isRecord(rawEff)) { + entries.push( + error( + [...path, 'effects', k], + 'effect-type', + `Sequence effect at index ${k} must be an object`, + ), + ); + continue; + } + const resolvedEff = resolveEffect(rawEff, globalEffects); + validateEffectShape(resolvedEff, [...path, 'effects', k], entries); + } +} diff --git a/packages/interact-debug/src/validate/helpers.ts b/packages/interact-debug/src/validate/helpers.ts new file mode 100644 index 00000000..6d88259c --- /dev/null +++ b/packages/interact-debug/src/validate/helpers.ts @@ -0,0 +1,183 @@ +import type { + ValidationResult, + ValidationEntry, + Scope, + TriggerType, + InteractConfig, +} from '../types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const TRIGGER_TYPES: TriggerType[] = [ + 'hover', + 'click', + 'viewEnter', + 'pageVisible', + 'animationEnd', + 'viewProgress', + 'pointerMove', + 'activate', + 'interest', +]; + +export const TRIGGER_TYPE_VALUES = ['once', 'repeat', 'alternate', 'state'] as const; +export const FILL_VALUES = ['none', 'forwards', 'backwards', 'both'] as const; +export const CONDITION_TYPES = ['media', 'container', 'selector'] as const; +export const RANGE_NAMES = [ + 'entry', + 'exit', + 'contain', + 'cover', + 'entry-crossing', + 'exit-crossing', +] as const; +export const STATE_ACTIONS = ['add', 'remove', 'toggle', 'clear'] as const; +export const SCRUB_TRANSITION_EASINGS = [ + 'linear', + 'hardBackOut', + 'easeOut', + 'elastic', + 'bounce', +] as const; + +/** Triggers that support time-based animation effects (duration + effectProperty). */ +export const TIME_TRIGGERS = new Set([ + 'viewEnter', + 'hover', + 'click', + 'pageVisible', + 'animationEnd', + 'activate', + 'interest', +]); + +/** Triggers that support scroll/pointer-driven scrub effects. */ +export const SCRUB_TRIGGERS = new Set(['viewProgress', 'pointerMove']); + +/** Triggers that support state effects (transition/transitionProperties). */ +export const STATE_TRIGGERS = new Set(['hover', 'click', 'activate', 'interest']); + +/** Triggers that REQUIRE params to be present. */ +export const TRIGGERS_REQUIRING_PARAMS = new Set(['animationEnd']); + +// --------------------------------------------------------------------------- +// Type guard +// --------------------------------------------------------------------------- + +/** Plain object (not null, not array). */ +export function isRecord(val: unknown): val is Record { + return val !== null && typeof val === 'object' && !Array.isArray(val); +} + +// --------------------------------------------------------------------------- +// Entry builders +// --------------------------------------------------------------------------- + +export function error(path: (string | number)[], rule: string, message: string): ValidationEntry { + return { severity: 'error', path, rule, message }; +} + +export function warning(path: (string | number)[], rule: string, message: string): ValidationEntry { + return { severity: 'warning', path, rule, message }; +} + +export type ValidationEntryBuilder = ( + path: (string | number)[], + rule: string, + message: string, +) => ValidationEntry; + +export function makeEntry(severity: 'error' | 'warning' | 'info'): ValidationEntryBuilder { + return (path, rule, message) => ({ severity, path, rule, message }); +} + +// --------------------------------------------------------------------------- +// Result builder +// --------------------------------------------------------------------------- + +export function toResult(entries: ValidationEntry[]): ValidationResult { + return { + valid: entries.every((e) => e.severity !== 'error'), + errors: entries.filter((e) => e.severity === 'error'), + warnings: entries.filter((e) => e.severity === 'warning'), + infos: entries.filter((e) => e.severity === 'info'), + }; +} + +// --------------------------------------------------------------------------- +// Scope filtering +// --------------------------------------------------------------------------- + +export function isInScope( + interaction: { key: string; trigger: string }, + index: number, + scope: Scope | undefined, +): boolean { + if (!scope) return true; + if (scope.interactionIndex !== undefined && scope.interactionIndex !== index) return false; + if (scope.key && interaction.key !== scope.key) return false; + if (scope.trigger && interaction.trigger !== scope.trigger) return false; + return true; +} + +// --------------------------------------------------------------------------- +// Effect / sequence resolution +// --------------------------------------------------------------------------- + +export function resolveEffect( + inline: Record, + globalEffects: Record>, +): Record { + const effectId = inline.effectId; + if (typeof effectId !== 'string') return inline; + const base = globalEffects[effectId]; + if (!base) return inline; + return { ...base, ...inline }; +} + +export function resolveSequence( + raw: Record, + globalSequences: Record>, +): Record { + if (typeof raw.sequenceId !== 'string') return raw; + const base = globalSequences[raw.sequenceId]; + if (!base) return raw; + return { ...base, ...raw }; +} + +// --------------------------------------------------------------------------- +// Global map builders +// --------------------------------------------------------------------------- + +export type GlobalMaps = { + globalEffects: Record>; + globalConditions: Record>; + globalSequences: Record>; +}; + +export function buildGlobalMaps(config: InteractConfig): GlobalMaps { + const globalEffects: Record> = {}; + if (config.effects) { + for (const [id, eff] of Object.entries(config.effects)) { + if (isRecord(eff)) globalEffects[id] = eff as Record; + } + } + + const globalConditions: Record> = {}; + if (config.conditions) { + for (const [id, cond] of Object.entries(config.conditions)) { + if (isRecord(cond)) globalConditions[id] = cond as Record; + } + } + + const globalSequences: Record> = {}; + if (config.sequences) { + for (const [id, seq] of Object.entries(config.sequences)) { + if (isRecord(seq)) globalSequences[id] = seq as Record; + } + } + + return { globalEffects, globalConditions, globalSequences }; +} diff --git a/packages/interact-debug/src/validate/index.ts b/packages/interact-debug/src/validate/index.ts new file mode 100644 index 00000000..7ba81000 --- /dev/null +++ b/packages/interact-debug/src/validate/index.ts @@ -0,0 +1,104 @@ +import type { + InteractArtifact, + InteractConfig, + ValidationResult, + ValidationEntry, + Scope, +} from '../types'; +import { toResult } from './helpers'; +import { validateSchema } from './configValidator'; +import { validateReferences } from './referenceValidator'; +import { validateCompatibility } from './compatibilityValidator'; +import { validateIntegration } from './integrationValidator'; +import { detectAntiPatterns } from './antiPatterns'; +import { validateRegistry } from './registryValidator'; + +export { validateSchema } from './configValidator'; +export { validateReferences } from './referenceValidator'; +export { validateCompatibility } from './compatibilityValidator'; +export { validateIntegration } from './integrationValidator'; +export { detectAntiPatterns } from './antiPatterns'; +export { validateRegistry } from './registryValidator'; +export { + TIME_TRIGGERS, + SCRUB_TRIGGERS, + STATE_TRIGGERS, + TRIGGER_TYPES, + isRecord, + toResult, + makeEntry, +} from './helpers'; +export { + ALL_PRESETS, + ENTRANCE_PRESETS, + ONGOING_PRESETS, + SCROLL_PRESETS, + MOUSE_PRESETS, + BG_SCROLL_PRESETS, +} from './registryValidator'; + +// --------------------------------------------------------------------------- +// Aggregate validators +// --------------------------------------------------------------------------- + +function mergeResults(...results: ValidationResult[]): ValidationResult { + const allEntries: ValidationEntry[] = []; + for (const r of results) { + allEntries.push(...r.errors, ...r.warnings, ...r.infos); + } + return toResult(allEntries); +} + +/** + * Run all validators on a full artifact (config + HTML + CSS + JS). + */ +export function validateAll(artifact: InteractArtifact, scope?: Scope): ValidationResult { + return mergeResults( + validateSchema(artifact.config, scope), + validateReferences(artifact.config, scope), + validateCompatibility(artifact.config, scope), + validateIntegration(artifact, scope), + detectAntiPatterns(artifact, scope), + validateRegistry(artifact, scope), + ); +} + +/** + * Config-only validation (no HTML/JS/CSS needed). + * Runs schema, reference, and compatibility validators. + */ +export function validateConfig(config: unknown, scope?: Scope): ValidationResult { + const schemaResult = validateSchema(config, scope); + if (!schemaResult.valid) return schemaResult; + + const typedConfig = config as InteractConfig; + return mergeResults( + schemaResult, + validateReferences(typedConfig, scope), + validateCompatibility(typedConfig, scope), + ); +} + +/** + * Validate a specific interaction by index. + */ +export function validateInteraction(artifact: InteractArtifact, index: number): ValidationResult { + const scope: Scope = { interactionIndex: index }; + return validateAll(artifact, scope); +} + +/** + * Validate all interactions/effects for a specific key. + */ +export function validateKey(artifact: InteractArtifact, key: string): ValidationResult { + const scope: Scope = { key }; + return validateAll(artifact, scope); +} + +/** + * Validate a specific named effect (all interactions that reference it). + */ +export function validateEffect(artifact: InteractArtifact, effectId: string): ValidationResult { + const scope: Scope = { effectId }; + return validateAll(artifact, scope); +} diff --git a/packages/interact-debug/src/validate/integrationValidator.ts b/packages/interact-debug/src/validate/integrationValidator.ts new file mode 100644 index 00000000..51872a67 --- /dev/null +++ b/packages/interact-debug/src/validate/integrationValidator.ts @@ -0,0 +1,273 @@ +import type { + InteractArtifact, + ValidationResult, + ValidationEntry, + Scope, + TriggerType, +} from '../types'; +import { + isRecord, + error, + warning, + makeEntry, + toResult, + isInScope, + resolveEffect, + buildGlobalMaps, +} from './helpers'; + +const info = makeEntry('info'); + +/** + * Validate the integration between config and the artifact's structured metadata. + * + * Consumes only typed fields (htmlMeta, setupMeta, registeredEffects, framework) + * rather than raw HTML/CSS/JS strings. Checks are skipped when the relevant + * metadata is unavailable, with an info-level note instead of a false result. + * + * Checks that can only be done reliably at runtime (CSS property scoping like + * overflow:hidden per-element, or pointer-events:none on specific selectors) + * are deferred to runtimeValidator and not attempted here. + */ +export function validateIntegration(artifact: InteractArtifact, scope?: Scope): ValidationResult { + const entries: ValidationEntry[] = []; + const { config, htmlMeta, setupMeta, registeredEffects } = artifact; + const { globalEffects } = buildGlobalMaps(config); + + const allHtmlKeys = new Set(htmlMeta?.keys ?? []); + + const triggersByKey = new Map>(); + let usesNamedEffect = false; + let needsA11y = false; + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + + const triggers = triggersByKey.get(interaction.key) ?? new Set(); + triggers.add(interaction.trigger); + triggersByKey.set(interaction.key, triggers); + + if (interaction.trigger === 'activate' || interaction.trigger === 'interest') { + needsA11y = true; + } + + if (!isInScope(interaction, i, scope)) continue; + const basePath = ['interactions', i] as (string | number)[]; + + // HTML key matching (skip if no htmlMeta available) + if (htmlMeta && !allHtmlKeys.has(interaction.key)) { + entries.push( + error( + [...basePath, 'key'], + 'key-missing-in-html', + `Key "${interaction.key}" has no matching data-interact-key or in HTML`, + ), + ); + } + + // FOUC rules for viewEnter (requires both htmlMeta and setupMeta) + if (interaction.trigger === 'viewEnter') { + const effectsForFouc = resolveInteractionEffects(interaction, globalEffects); + const isOnce = effectsForFouc.every((e) => !('triggerType' in e) || e.triggerType === 'once'); + const sameElement = effectsForFouc.every((e) => !('key' in e) || e.key === interaction.key); + + if (isOnce && sameElement && htmlMeta && setupMeta) { + const hasInitial = interaction.key in (htmlMeta.initials ?? {}); + const hasGenerate = setupMeta.hasGenerate === true; + + if (hasInitial && !hasGenerate) { + entries.push( + error( + [...basePath], + 'fouc-missing-generate', + `viewEnter+once on key "${interaction.key}" has data-interact-initial but generate() CSS is missing; both are required`, + ), + ); + } + if (!hasInitial && hasGenerate) { + entries.push( + warning( + [...basePath], + 'fouc-missing-initial', + `viewEnter+once on key "${interaction.key}" has generate() CSS but data-interact-initial is missing on the element`, + ), + ); + } + } + + if (htmlMeta && !isOnce && interaction.key in (htmlMeta.initials ?? {})) { + entries.push( + warning( + [...basePath], + 'initial-on-non-once', + `data-interact-initial on key "${interaction.key}" is only valid for viewEnter+once; this interaction uses repeat/alternate/state`, + ), + ); + } + } + + // Detect namedEffect usage (config-driven) + if (interaction.effects) { + for (const eff of interaction.effects) { + const resolved = resolveEffect(eff as Record, globalEffects); + if ('namedEffect' in resolved) usesNamedEffect = true; + } + } + if (interaction.sequences) { + for (const seq of interaction.sequences) { + const seqObj = seq as Record; + const seqEffects = Array.isArray(seqObj.effects) ? seqObj.effects : []; + for (const eff of seqEffects) { + const resolved = resolveEffect(eff as Record, globalEffects); + if ('namedEffect' in resolved) usesNamedEffect = true; + } + } + } + } + + // Setup metadata checks (skip entirely when setupMeta is unavailable) + if (setupMeta) { + if (usesNamedEffect) { + if (setupMeta.hasRegisterEffects === false) { + entries.push( + error( + ['setup'], + 'register-effects-missing', + 'Config uses namedEffect but registerEffects() is not called', + ), + ); + } else if (setupMeta.registerBeforeCreate === false) { + entries.push( + error( + ['setup'], + 'register-effects-order', + 'registerEffects() must be called before Interact.create()', + ), + ); + } + + if (registeredEffects && registeredEffects.length > 0) { + const registeredSet = new Set(registeredEffects); + for (const interaction of config.interactions) { + if (interaction.effects) { + for (const eff of interaction.effects) { + const resolved = resolveEffect(eff as Record, globalEffects); + if (isRecord(resolved.namedEffect)) { + const type = (resolved.namedEffect as Record).type; + if (typeof type === 'string' && !registeredSet.has(type)) { + entries.push( + warning( + ['setup'], + 'named-effect-not-registered', + `namedEffect.type "${type}" is used but not found in registerEffects() call`, + ), + ); + } + } + } + } + } + } + } + + if (setupMeta.setupBeforeCreate === false) { + entries.push( + error(['setup'], 'setup-order', 'Interact.setup() must be called before Interact.create()'), + ); + } + + if (setupMeta.hasDestroy === false) { + entries.push( + warning( + ['setup'], + 'missing-destroy', + 'No destroy() call found; consider adding cleanup to avoid memory leaks', + ), + ); + } + + if (needsA11y && setupMeta.hasA11yTriggers === false) { + entries.push( + error( + ['setup'], + 'missing-a11y-triggers', + 'Config uses activate/interest triggers but Interact.allowA11yTriggers is not set to true', + ), + ); + } + } else if (usesNamedEffect || needsA11y) { + entries.push( + info( + ['setup'], + 'setup-meta-unavailable', + 'JS setup metadata not available; JS setup checks (registerEffects, destroy, a11y) were skipped. Use runtime validation for full coverage.', + ), + ); + } + + // Interact-element child checks (from htmlMeta.interactElements) + if (htmlMeta?.interactElements) { + for (const el of htmlMeta.interactElements) { + if (!el.hasChild) { + entries.push( + error( + ['html'], + 'interact-element-no-child', + ` for key "${el.key || '?'}" has no child element (library uses firstElementChild)`, + ), + ); + } + } + } + + // Accessibility: click without activate, hover without interest (config-driven) + if (!scope) { + for (const [key, triggers] of triggersByKey) { + if (triggers.has('click') && !triggers.has('activate')) { + entries.push( + warning( + ['interactions'], + 'click-without-activate', + `Key "${key}" has click trigger but no matching activate trigger for keyboard support`, + ), + ); + } + if (triggers.has('hover') && !triggers.has('interest')) { + entries.push( + warning( + ['interactions'], + 'hover-without-interest', + `Key "${key}" has hover trigger but no matching interest trigger for focus support`, + ), + ); + } + } + } + + // Note skipped checks when metadata is unavailable + if (!htmlMeta) { + entries.push( + info( + ['html'], + 'html-meta-unavailable', + 'HTML metadata not available; HTML integration checks (key matching, FOUC, interact-element) were skipped. Use runtime validation for full coverage.', + ), + ); + } + + return toResult(entries); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveInteractionEffects( + interaction: Record, + globalEffects: Record>, +): Record[] { + if (!Array.isArray(interaction.effects)) return []; + return interaction.effects.map((e: unknown) => + isRecord(e) ? resolveEffect(e as Record, globalEffects) : {}, + ); +} diff --git a/packages/interact-debug/src/validate/referenceValidator.ts b/packages/interact-debug/src/validate/referenceValidator.ts new file mode 100644 index 00000000..7b9d1e3c --- /dev/null +++ b/packages/interact-debug/src/validate/referenceValidator.ts @@ -0,0 +1,246 @@ +import type { InteractConfig, ValidationResult, Scope } from '../types'; +import { isRecord, error, warning, toResult, isInScope } from './helpers'; + +/** + * Validate that all cross-references in the config resolve correctly: + * - effectId -> config.effects + * - conditions -> config.conditions + * - sequenceId -> config.sequences + * - animationEnd params.effectId -> existing effect + * - Cross-key wiring (effect.key targets existing interaction key) + * - Orphan detection for unused effects/conditions/sequences + */ +export function validateReferences(config: InteractConfig, scope?: Scope): ValidationResult { + const entries = []; + const effects = config.effects ?? {}; + const conditions = config.conditions ?? {}; + const sequences = config.sequences ?? {}; + + const referencedEffects = new Set(); + const referencedConditions = new Set(); + const referencedSequences = new Set(); + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + if (!isInScope(interaction, i, scope)) continue; + const basePath = ['interactions', i] as (string | number)[]; + + // Interaction-level conditions + if (interaction.conditions) { + for (let c = 0; c < interaction.conditions.length; c++) { + const ref = interaction.conditions[c]; + referencedConditions.add(ref); + if (!(ref in conditions)) { + entries.push( + error( + [...basePath, 'conditions', c], + 'condition-ref-missing', + `Condition "${ref}" is not defined in config.conditions`, + ), + ); + } + } + } + + // Effects + if (interaction.effects) { + for (let j = 0; j < interaction.effects.length; j++) { + const eff = interaction.effects[j] as Record; + const effPath = [...basePath, 'effects', j]; + + if (typeof eff.effectId === 'string') { + referencedEffects.add(eff.effectId); + if (!(eff.effectId in effects)) { + entries.push( + error( + [...effPath, 'effectId'], + 'effect-ref-missing', + `Effect "${eff.effectId}" is not defined in config.effects`, + ), + ); + } + } + + collectConditionRefs(eff, effPath, conditions, referencedConditions, entries); + + if (typeof eff.key === 'string' && eff.key !== interaction.key) { + const targetExists = config.interactions.some((ix) => ix.key === eff.key); + if (!targetExists) { + entries.push( + warning( + [...effPath, 'key'], + 'cross-key-missing', + `Effect targets key "${eff.key}" but no interaction with that key exists`, + ), + ); + } + } + } + } + + // Sequences + if (interaction.sequences) { + for (let j = 0; j < interaction.sequences.length; j++) { + const seq = interaction.sequences[j] as Record; + const seqPath = [...basePath, 'sequences', j]; + + if (typeof seq.sequenceId === 'string') { + referencedSequences.add(seq.sequenceId); + if (!(seq.sequenceId in sequences)) { + entries.push( + error( + [...seqPath, 'sequenceId'], + 'sequence-ref-missing', + `Sequence "${seq.sequenceId}" is not defined in config.sequences`, + ), + ); + } + } + + collectConditionRefs(seq, seqPath, conditions, referencedConditions, entries); + + const resolved = + typeof seq.sequenceId === 'string' && isRecord(sequences[seq.sequenceId]) + ? (sequences[seq.sequenceId] as Record) + : null; + const seqEffects = Array.isArray(seq.effects) ? seq.effects : []; + const effectsToCheck = + seqEffects.length > 0 + ? seqEffects + : resolved && Array.isArray(resolved.effects) + ? resolved.effects + : []; + + for (let k = 0; k < effectsToCheck.length; k++) { + const eff = effectsToCheck[k] as Record; + if (typeof eff.effectId === 'string') { + referencedEffects.add(eff.effectId); + if (!(eff.effectId in effects)) { + entries.push( + error( + [...seqPath, 'effects', k, 'effectId'], + 'effect-ref-missing', + `Effect "${eff.effectId}" is not defined in config.effects`, + ), + ); + } + } + collectConditionRefs( + eff, + [...seqPath, 'effects', k], + conditions, + referencedConditions, + entries, + ); + } + } + } + + // animationEnd params.effectId + if (interaction.trigger === 'animationEnd' && isRecord(interaction.params)) { + const params = interaction.params as Record; + if (typeof params.effectId === 'string') { + referencedEffects.add(params.effectId); + if (!(params.effectId in effects)) { + entries.push( + error( + [...basePath, 'params', 'effectId'], + 'animationEnd-effect-ref', + `animationEnd params.effectId "${params.effectId}" is not defined in config.effects`, + ), + ); + } + } + } + } + + // Scan top-level sequences for effectId/condition refs + for (const [seqId, seq] of Object.entries(sequences)) { + if (!isRecord(seq)) continue; + const seqObj = seq as Record; + if (Array.isArray(seqObj.effects)) { + for (let k = 0; k < seqObj.effects.length; k++) { + const eff = seqObj.effects[k] as Record; + if (typeof eff.effectId === 'string') { + referencedEffects.add(eff.effectId); + if (!(eff.effectId in effects)) { + entries.push( + error( + ['sequences', seqId, 'effects', k, 'effectId'], + 'effect-ref-missing', + `Effect "${eff.effectId}" is not defined in config.effects`, + ), + ); + } + } + } + } + if (Array.isArray(seqObj.conditions)) { + for (const ref of seqObj.conditions as string[]) { + referencedConditions.add(ref); + } + } + } + + // Orphan detection (only when no scope filter) + if (!scope) { + for (const id of Object.keys(effects)) { + if (!referencedEffects.has(id)) { + entries.push( + warning( + ['effects', id], + 'orphan-effect', + `Effect "${id}" is defined but never referenced by any interaction`, + ), + ); + } + } + for (const id of Object.keys(conditions)) { + if (!referencedConditions.has(id)) { + entries.push( + warning( + ['conditions', id], + 'orphan-condition', + `Condition "${id}" is defined but never referenced`, + ), + ); + } + } + for (const id of Object.keys(sequences)) { + if (!referencedSequences.has(id)) { + entries.push( + warning( + ['sequences', id], + 'orphan-sequence', + `Sequence "${id}" is defined but never referenced by any interaction`, + ), + ); + } + } + } + + return toResult(entries); +} + +function collectConditionRefs( + obj: Record, + basePath: (string | number)[], + globalConditions: Record, + referencedConditions: Set, + entries: ReturnType[], +): void { + if (!Array.isArray(obj.conditions)) return; + for (let c = 0; c < obj.conditions.length; c++) { + const ref = obj.conditions[c] as string; + referencedConditions.add(ref); + if (!(ref in globalConditions)) { + entries.push( + error( + [...basePath, 'conditions', c], + 'condition-ref-missing', + `Condition "${ref}" is not defined in config.conditions`, + ), + ); + } + } +} diff --git a/packages/interact-debug/src/validate/registryValidator.ts b/packages/interact-debug/src/validate/registryValidator.ts new file mode 100644 index 00000000..a193f4d7 --- /dev/null +++ b/packages/interact-debug/src/validate/registryValidator.ts @@ -0,0 +1,226 @@ +import type { InteractArtifact, ValidationResult, ValidationEntry, Scope } from '../types'; +import { + isRecord, + error, + toResult, + isInScope, + resolveEffect, + resolveSequence, + buildGlobalMaps, +} from './helpers'; + +// --------------------------------------------------------------------------- +// Known presets from @wix/motion-presets (75 total) +// --------------------------------------------------------------------------- + +const ENTRANCE_PRESETS = [ + 'ArcIn', + 'BlurIn', + 'BounceIn', + 'CurveIn', + 'DropIn', + 'ExpandIn', + 'FadeIn', + 'FlipIn', + 'FloatIn', + 'FoldIn', + 'GlideIn', + 'RevealIn', + 'ShapeIn', + 'ShuttersIn', + 'SlideIn', + 'SpinIn', + 'TiltIn', + 'TurnIn', + 'WinkIn', +] as const; + +const ONGOING_PRESETS = [ + 'Bounce', + 'Breathe', + 'Cross', + 'Flash', + 'Flip', + 'Fold', + 'Jello', + 'Poke', + 'Pulse', + 'Rubber', + 'Spin', + 'Swing', + 'Wiggle', +] as const; + +const SCROLL_PRESETS = [ + 'ArcScroll', + 'BlurScroll', + 'FadeScroll', + 'FlipScroll', + 'GrowScroll', + 'MoveScroll', + 'PanScroll', + 'ParallaxScroll', + 'RevealScroll', + 'ShapeScroll', + 'ShuttersScroll', + 'ShrinkScroll', + 'SkewPanScroll', + 'SlideScroll', + 'Spin3dScroll', + 'SpinScroll', + 'StretchScroll', + 'TiltScroll', + 'TurnScroll', +] as const; + +const MOUSE_PRESETS = [ + 'AiryMouse', + 'BlobMouse', + 'BlurMouse', + 'BounceMouse', + 'CustomMouse', + 'ScaleMouse', + 'SkewMouse', + 'SpinMouse', + 'SwivelMouse', + 'Tilt3DMouse', + 'Track3DMouse', + 'TrackMouse', +] as const; + +const BG_SCROLL_PRESETS = [ + 'BgCloseUp', + 'BgFade', + 'BgFadeBack', + 'BgFake3D', + 'BgPan', + 'BgParallax', + 'BgPullBack', + 'BgReveal', + 'BgRotate', + 'BgSkew', + 'BgZoom', + 'ImageParallax', +] as const; + +const ALL_PRESETS = new Set([ + ...ENTRANCE_PRESETS, + ...ONGOING_PRESETS, + ...SCROLL_PRESETS, + ...MOUSE_PRESETS, + ...BG_SCROLL_PRESETS, +]); + +export { + ENTRANCE_PRESETS, + ONGOING_PRESETS, + SCROLL_PRESETS, + MOUSE_PRESETS, + BG_SCROLL_PRESETS, + ALL_PRESETS, +}; + +/** + * Validate that all namedEffect.type values reference known presets + * or are covered by registerEffects() calls. + * + * When `registeredEffects` is undefined (metadata unavailable), only the + * "is this a known preset name" check runs. The "was it registered" check + * is skipped to avoid false positives. + */ +export function validateRegistry(artifact: InteractArtifact, scope?: Scope): ValidationResult { + const entries: ValidationEntry[] = []; + const { config, registeredEffects } = artifact; + const { globalEffects, globalSequences } = buildGlobalMaps(config); + const hasRegistrationInfo = registeredEffects !== undefined; + const registeredSet = new Set(registeredEffects ?? []); + + for (let i = 0; i < config.interactions.length; i++) { + const interaction = config.interactions[i]; + if (!isInScope(interaction, i, scope)) continue; + const basePath = ['interactions', i] as (string | number)[]; + + if (interaction.effects) { + for (let j = 0; j < interaction.effects.length; j++) { + const raw = interaction.effects[j] as Record; + if (!isRecord(raw)) continue; + const resolved = resolveEffect(raw, globalEffects); + checkNamedEffect( + resolved, + [...basePath, 'effects', j], + hasRegistrationInfo, + registeredSet, + entries, + ); + } + } + + if (interaction.sequences) { + for (let j = 0; j < interaction.sequences.length; j++) { + const seq = interaction.sequences[j] as Record; + if (!isRecord(seq)) continue; + const seqResolved = resolveSequence(seq, globalSequences); + const seqEffects = Array.isArray(seqResolved.effects) ? seqResolved.effects : []; + + for (let k = 0; k < seqEffects.length; k++) { + const effRaw = seqEffects[k] as Record; + if (!isRecord(effRaw)) continue; + const resolved = resolveEffect(effRaw, globalEffects); + checkNamedEffect( + resolved, + [...basePath, 'sequences', j, 'effects', k], + hasRegistrationInfo, + registeredSet, + entries, + ); + } + } + } + } + + return toResult(entries); +} + +function checkNamedEffect( + eff: Record, + path: (string | number)[], + hasRegistrationInfo: boolean, + registeredSet: Set, + entries: ValidationEntry[], +): void { + if (!isRecord(eff.namedEffect)) return; + const ne = eff.namedEffect as Record; + const type = ne.type; + if (typeof type !== 'string') return; + + const isKnownPreset = ALL_PRESETS.has(type); + const isRegistered = registeredSet.has(type); + + if (!isKnownPreset && !isRegistered) { + if (hasRegistrationInfo) { + entries.push( + error( + [...path, 'namedEffect', 'type'], + 'unknown-named-effect', + `namedEffect.type "${type}" is not a known @wix/motion-presets preset and was not found in registerEffects()`, + ), + ); + } else { + entries.push( + error( + [...path, 'namedEffect', 'type'], + 'unknown-named-effect', + `namedEffect.type "${type}" is not a known @wix/motion-presets preset; verify it is registered via registerEffects()`, + ), + ); + } + } else if (isKnownPreset && !isRegistered && hasRegistrationInfo) { + entries.push( + error( + [...path, 'namedEffect', 'type'], + 'preset-not-registered', + `namedEffect.type "${type}" is a known preset but was not registered via registerEffects(); call registerEffects({ ${type} }) before Interact.create()`, + ), + ); + } +} diff --git a/packages/interact-debug/test/antiPatterns.spec.ts b/packages/interact-debug/test/antiPatterns.spec.ts new file mode 100644 index 00000000..1bb479e8 --- /dev/null +++ b/packages/interact-debug/test/antiPatterns.spec.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from 'vitest'; +import { detectAntiPatterns } from '../src/validate/antiPatterns'; +import type { InteractArtifact } from '../src/types'; + +function makeArtifact(config: any): InteractArtifact { + return { + config, + sourceType: 'separated', + confidence: 'high', + }; +} + +describe('detectAntiPatterns', () => { + it('passes for a clean config', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }] }, duration: 500 }, + ], + }, + ], + }), + ); + expect(result.valid).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + + it('warns on duplicate key+trigger', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'a', keyframes: [{}] }, duration: 500 }], + }, + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ keyframeEffect: { name: 'b', keyframes: [{}] }, duration: 300 }], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'duplicate-key-trigger')).toBe(true); + }); + + it('warns on viewEnter same-element with non-once triggerType', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { name: 'a', keyframes: [{}] }, + duration: 500, + triggerType: 'repeat', + }, + ], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'viewEnter-same-element-non-once')).toBe(true); + }); + + it('does not warn on viewEnter non-once with separate target key', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'source', + trigger: 'viewEnter', + effects: [ + { + key: 'target', + keyframeEffect: { name: 'a', keyframes: [{}] }, + duration: 500, + triggerType: 'repeat', + }, + ], + }, + ], + }), + ); + expect( + result.warnings.filter((w) => w.rule === 'viewEnter-same-element-non-once'), + ).toHaveLength(0); + }); + + it('warns on hover layout-changing effect on same element', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'btn', + trigger: 'hover', + effects: [ + { + keyframeEffect: { + name: 'grow', + keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.2)' }], + }, + duration: 300, + }, + ], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'hover-layout-same-element')).toBe(true); + }); + + it('does not warn on hover layout effect with selector', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'btn', + trigger: 'hover', + effects: [ + { + selector: '.inner', + keyframeEffect: { + name: 'grow', + keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.2)' }], + }, + duration: 300, + }, + ], + }, + ], + }), + ); + expect(result.warnings.filter((w) => w.rule === 'hover-layout-same-element')).toHaveLength(0); + }); + + it('warns on pointerMove hitArea:self with layout changes on same element', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'card', + trigger: 'pointerMove', + params: { hitArea: 'self' }, + effects: [ + { + keyframeEffect: { + name: 'tilt', + keyframes: [{ transform: 'translate(0px)' }, { transform: 'translate(10px)' }], + }, + }, + ], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'pointerMove-self-layout')).toBe(true); + }); + + it('warns on duration of 0', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'a', + trigger: 'hover', + effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 0 }], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'duration-zero')).toBe(true); + }); + + it('warns on extreme duration (>10s)', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'a', + trigger: 'hover', + effects: [{ keyframeEffect: { name: 'x', keyframes: [{}] }, duration: 15000 }], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'duration-extreme')).toBe(true); + }); + + it('warns on viewEnter repeat without threshold', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { name: 'a', keyframes: [{}] }, + duration: 500, + triggerType: 'repeat', + }, + ], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'viewEnter-repeat-no-threshold')).toBe(true); + }); + + it('does not warn on viewEnter repeat with threshold', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + params: { threshold: 0.5 }, + effects: [ + { + keyframeEffect: { name: 'a', keyframes: [{}] }, + duration: 500, + triggerType: 'repeat', + }, + ], + }, + ], + }), + ); + expect(result.warnings.filter((w) => w.rule === 'viewEnter-repeat-no-threshold')).toHaveLength( + 0, + ); + }); + + it('warns on inverted rangeStart/rangeEnd', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + effects: [ + { + keyframeEffect: { name: 'x', keyframes: [{ opacity: 0 }] }, + rangeStart: { name: 'exit' }, + rangeEnd: { name: 'entry' }, + }, + ], + }, + ], + }), + ); + expect(result.warnings.some((w) => w.rule === 'range-inverted')).toBe(true); + }); + + it('does not warn on correct range order', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + effects: [ + { + keyframeEffect: { name: 'x', keyframes: [{ opacity: 0 }] }, + rangeStart: { name: 'entry' }, + rangeEnd: { name: 'cover' }, + }, + ], + }, + ], + }), + ); + expect(result.warnings.filter((w) => w.rule === 'range-inverted')).toHaveLength(0); + }); + + it('resolves effectId for anti-pattern checks', () => { + const result = detectAntiPatterns( + makeArtifact({ + effects: { + grow: { + keyframeEffect: { name: 'grow', keyframes: [{ transform: 'scale(1.2)' }] }, + duration: 300, + }, + }, + interactions: [{ key: 'btn', trigger: 'hover', effects: [{ effectId: 'grow' }] }], + }), + ); + expect(result.warnings.some((w) => w.rule === 'hover-layout-same-element')).toBe(true); + }); +}); diff --git a/packages/interact-debug/test/artifact.spec.ts b/packages/interact-debug/test/artifact.spec.ts new file mode 100644 index 00000000..d0c473c9 --- /dev/null +++ b/packages/interact-debug/test/artifact.spec.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { parseArtifact } from '../src/artifact'; +import type { InteractConfig } from '../src/types'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const MINIMAL_CONFIG: InteractConfig = { + effects: { + fadeIn: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 500, + }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + effects: [{ effectId: 'fadeIn' }], + }, + ], +}; + +describe('parseArtifact', () => { + describe('separated input', () => { + it('assembles config + html + css + js into an artifact', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '
Hello
', + css: '.hero { opacity: 0; }', + js: 'import { Interact } from "@wix/interact";\nInteract.create(config);', + }); + + expect(artifact.config).toEqual(MINIMAL_CONFIG); + expect(artifact.raw?.html).toContain('data-interact-key="hero"'); + expect(artifact.raw?.css).toContain('.hero'); + expect(artifact.raw?.js).toContain('Interact.create'); + expect(artifact.sourceType).toBe('separated'); + expect(artifact.framework).toBe('vanilla'); + expect(artifact.confidence).toBe('parsed'); + }); + + it('populates htmlMeta from HTML string', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '
Hello
', + }); + + expect(artifact.htmlMeta).toBeDefined(); + expect(artifact.htmlMeta!.keys).toContain('hero'); + }); + + it('populates setupMeta from JS string', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '
', + js: 'registerEffects([FadeIn]);\nInteract.create(config);\nInteract.destroy();', + }); + + expect(artifact.setupMeta).toBeDefined(); + expect(artifact.setupMeta!.hasRegisterEffects).toBe(true); + expect(artifact.setupMeta!.registerBeforeCreate).toBe(true); + expect(artifact.setupMeta!.hasDestroy).toBe(true); + }); + + it('uses pre-parsed metadata when provided (high confidence)', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + htmlMeta: { keys: ['hero', 'banner'], initials: { hero: true }, interactElements: [] }, + framework: 'react', + registeredEffects: ['FadeIn'], + }); + + expect(artifact.confidence).toBe('high'); + expect(artifact.htmlMeta!.keys).toEqual(['hero', 'banner']); + expect(artifact.framework).toBe('react'); + expect(artifact.registeredEffects).toEqual(['FadeIn']); + }); + + it('detects react framework from import', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '
', + js: 'import { Interaction } from "@wix/interact/react";', + }); + expect(artifact.framework).toBe('react'); + }); + + it('detects web framework from import', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '', + js: 'import "@wix/interact/web";', + }); + expect(artifact.framework).toBe('web'); + }); + + it('extracts registered effects from registerEffects([...]) call', async () => { + const artifact = await parseArtifact({ + type: 'separated', + config: MINIMAL_CONFIG, + html: '
', + js: 'registerEffects([FadeIn, SlideIn, BounceIn]);', + }); + expect(artifact.registeredEffects).toEqual(['FadeIn', 'SlideIn', 'BounceIn']); + }); + }); + + describe('mixed input', () => { + it('extracts config, html, css, and js from a full HTML document', async () => { + const source = ` + + + + + + +
Hello
+ + +`; + + const artifact = await parseArtifact({ type: 'mixed', source }); + + expect(artifact.sourceType).toBe('mixed'); + expect(artifact.confidence).toBe('parsed'); + expect(artifact.config.interactions).toHaveLength(1); + expect(artifact.config.interactions[0].key).toBe('hero'); + expect(artifact.config.effects).toHaveProperty('fadeIn'); + expect(artifact.htmlMeta?.keys).toContain('hero'); + expect(artifact.raw?.html).toContain('data-interact-key="hero"'); + expect(artifact.raw?.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.raw?.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.htmlMeta?.keys).toContain('hero'); + expect(artifact.raw?.css).toContain('.hero'); + expect(artifact.raw?.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.htmlMeta?.keys).toContain('hero'); + expect(artifact.htmlMeta?.keys).toContain('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.raw?.css).toContain('.base'); + expect(artifact.raw?.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..52edc361 --- /dev/null +++ b/packages/interact-debug/test/compatibilityValidator.spec.ts @@ -0,0 +1,365 @@ +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/configInspector.spec.ts b/packages/interact-debug/test/configInspector.spec.ts new file mode 100644 index 00000000..c9f7cd89 --- /dev/null +++ b/packages/interact-debug/test/configInspector.spec.ts @@ -0,0 +1,197 @@ +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/configValidator.spec.ts b/packages/interact-debug/test/configValidator.spec.ts new file mode 100644 index 00000000..89ad899c --- /dev/null +++ b/packages/interact-debug/test/configValidator.spec.ts @@ -0,0 +1,826 @@ +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/domInspector.spec.ts b/packages/interact-debug/test/domInspector.spec.ts new file mode 100644 index 00000000..edc2008c --- /dev/null +++ b/packages/interact-debug/test/domInspector.spec.ts @@ -0,0 +1,147 @@ +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/integrationValidator.spec.ts b/packages/interact-debug/test/integrationValidator.spec.ts new file mode 100644 index 00000000..13f1845d --- /dev/null +++ b/packages/interact-debug/test/integrationValidator.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from 'vitest'; +import { validateIntegration } from '../src/validate/integrationValidator'; +import type { InteractArtifact, HtmlMetadata, SetupMetadata } 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, + ], + }, + sourceType: 'separated', + confidence: 'high', + htmlMeta: { + keys: ['hero'], + initials: {}, + interactElements: [], + }, + setupMeta: { + hasGenerate: false, + hasDestroy: true, + hasA11yTriggers: false, + hasRegisterEffects: false, + registerBeforeCreate: undefined, + setupBeforeCreate: undefined, + }, + ...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({ + htmlMeta: { keys: [], initials: {}, interactElements: [] }, + }), + ); + 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, + ], + }, + setupMeta: { + hasRegisterEffects: false, + hasDestroy: true, + hasA11yTriggers: false, + }, + }); + 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, + ], + }, + setupMeta: { + hasRegisterEffects: true, + registerBeforeCreate: false, + hasDestroy: true, + hasA11yTriggers: false, + }, + }); + 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({ + setupMeta: { + hasDestroy: false, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + 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, + ], + }, + setupMeta: { + hasA11yTriggers: false, + hasDestroy: true, + hasRegisterEffects: false, + }, + }); + 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], + }, + htmlMeta: { keys: ['btn'], initials: {}, interactElements: [] }, + }); + 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], + }, + htmlMeta: { keys: ['card'], initials: {}, interactElements: [] }, + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'hover-without-interest')).toBe(true); + }); + + it('errors when has no child', () => { + const artifact = makeArtifact({ + htmlMeta: { + keys: ['hero'], + initials: {}, + interactElements: [{ key: 'hero', hasChild: false }], + }, + 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({ + htmlMeta: { + keys: ['hero'], + initials: {}, + interactElements: [{ key: 'hero', hasChild: true }], + }, + 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({ + setupMeta: { + setupBeforeCreate: false, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'setup-order')).toBe(true); + }); + + it('skips setup checks and emits info when setupMeta is unavailable', () => { + const artifact = makeArtifact({ + config: { + effects: { entrance: { namedEffect: { type: 'FadeIn' }, duration: 500 } as any }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'entrance' }] } as any, + ], + }, + setupMeta: undefined, + }); + const result = validateIntegration(artifact); + expect(result.errors.filter((e) => e.rule === 'register-effects-missing')).toHaveLength(0); + expect(result.infos.some((i) => i.rule === 'setup-meta-unavailable')).toBe(true); + }); + + it('skips HTML checks and emits info when htmlMeta is unavailable', () => { + const artifact = makeArtifact({ + htmlMeta: undefined, + }); + const result = validateIntegration(artifact); + expect(result.errors.filter((e) => e.rule === 'key-missing-in-html')).toHaveLength(0); + expect(result.infos.some((i) => i.rule === 'html-meta-unavailable')).toBe(true); + }); + + it('FOUC: errors when initial present but generate missing', () => { + const artifact = makeArtifact({ + htmlMeta: { + keys: ['hero'], + initials: { hero: true }, + interactElements: [], + }, + setupMeta: { + hasGenerate: false, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = validateIntegration(artifact); + expect(result.errors.some((e) => e.rule === 'fouc-missing-generate')).toBe(true); + }); + + it('FOUC: warns when generate present but initial missing', () => { + const artifact = makeArtifact({ + htmlMeta: { + keys: ['hero'], + initials: {}, + interactElements: [], + }, + setupMeta: { + hasGenerate: true, + hasDestroy: true, + hasRegisterEffects: false, + hasA11yTriggers: false, + }, + }); + const result = validateIntegration(artifact); + expect(result.warnings.some((w) => w.rule === 'fouc-missing-initial')).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..e9e34d25 --- /dev/null +++ b/packages/interact-debug/test/logger.spec.ts @@ -0,0 +1,154 @@ +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/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/referenceValidator.spec.ts b/packages/interact-debug/test/referenceValidator.spec.ts new file mode 100644 index 00000000..95c61fb3 --- /dev/null +++ b/packages/interact-debug/test/referenceValidator.spec.ts @@ -0,0 +1,252 @@ +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..11e15f5b --- /dev/null +++ b/packages/interact-debug/test/registryValidator.spec.ts @@ -0,0 +1,136 @@ +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, + sourceType: 'separated', + confidence: 'high', + 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/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/runtimeValidator.spec.ts b/packages/interact-debug/test/runtimeValidator.spec.ts new file mode 100644 index 00000000..5a9c3574 --- /dev/null +++ b/packages/interact-debug/test/runtimeValidator.spec.ts @@ -0,0 +1,237 @@ +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); + }); + }); +}); 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/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..3bdcaba6 --- /dev/null +++ b/packages/interact-debug/vite.config.ts @@ -0,0 +1,47 @@ +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'), + eval: path.resolve(__dirname, 'src/eval/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:child_process', + 'node:fs', + 'node:fs/promises', + 'node:os', + '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..8f9e0bfa --- /dev/null +++ b/packages/interact-debug/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: [], + exclude: ['test/rulesEval.spec.ts', 'node_modules/**'], + }, +}); 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, + }, +}); diff --git a/packages/interact/rules/click.md b/packages/interact/rules/click.md index 83a2e0d8..ba94219f 100644 --- a/packages/interact/rules/click.md +++ b/packages/interact/rules/click.md @@ -173,7 +173,7 @@ Use sequences when a click should sync/stagger animations across multiple elemen offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -186,4 +186,4 @@ Use sequences when a click should sync/stagger animations across multiple elemen - `[SOURCE_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset for staggering each child's animation start, in milliseconds. - `[OFFSET_EASING]` — easing curve for the offset staggering distribution. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of, or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of, or a reference to a time-based animation effect. diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index cb13b9df..2bb48912 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -38,7 +38,7 @@ Each item here is CRITICAL — ignoring any of them will break animations. events and flickering. Use `selector` to target a child element, or set the effect's `key` to a different element. - **CRITICAL**: For `pointerMove` trigger MUST AVOID using the same element as both source and target with `hitArea: 'self'` and effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. - **CRITICAL — Do NOT guess preset options**: If you don't know the expected type/structure for a `namedEffect` param, omit it — rely on defaults rather than guessing. -- **Reduced motion**: Use conditions to provide gentler alternatives (shorter durations, fewer transforms, no perpetual motion) for users who prefer reduced motion. You can also set `Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches` to force a global reduced-motion behavior programmatically. +- **CRITICAL — Reduced motion**: You MUST define a `prefers-reduced-motion` condition and apply it to entrance/ongoing animations. This is required for accessibility compliance. Define: `'reduced-motion': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }` and reference it via `conditions: ['reduced-motion']` on interactions or effects that should be suppressed/gentled. You can also set `Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches` to force a global reduced-motion behavior programmatically. - **Perspective**: Prefer `transform: perspective(...)` inside keyframes. Use the CSS `perspective` property only when multiple children share the same `perspective-origin`. --- @@ -569,17 +569,21 @@ Named conditions that gate interactions, effects, or sequences. Attach via `conditions: ['[CONDITION_ID]']` on interactions, effects, or sequences. On an interaction, conditions gate the entire trigger; on an effect, only that specific effect is skipped. All listed conditions must pass. +**CRITICAL — Required condition**: Every config with entrance or ongoing animations MUST define and use a `reduced-motion` condition. This is required for accessibility. + ### Examples ```ts conditions: { + 'reduced-motion': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, // REQUIRED for a11y 'desktop': { type: 'media', predicate: '(min-width: 768px)' }, 'hover-device': { type: 'media', predicate: '(hover: hover)' }, - 'reduced-motion': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, 'odd-items': { type: 'selector', predicate: ':nth-of-type(odd)' }, } ``` +Apply `reduced-motion` on interactions or effects that should be suppressed/simplified for users who prefer reduced motion: `conditions: ['reduced-motion']`. For entrance animations, this typically means gating the entire interaction. + --- ## FOUC Prevention @@ -666,16 +670,41 @@ The target element is what the effect animates. Resolved in priority order: --- +## Performance & Complexity Guidelines + +**Compositor-friendly animations**: Prefer animating `transform` and `opacity` properties. Avoid animating layout properties (`width`, `height`, `margin`, `padding`, `top`, `left`, `right`, `bottom`) in keyframes — these trigger expensive layout recalculations. Use `transform: scale()` instead of `width`/`height`, and `transform: translate()` instead of position properties. + +**Complexity budget**: Keep configs simple and focused: + +- Prefer ≤10 interactions per config +- Prefer ≤5 effects + sequences per interaction +- Prefer ≤5 conditions +- Avoid deeply nested sequences (sequences referencing other sequences via `sequenceId`) +- Minimize cross-key effects (effects targeting a different `key` than the interaction's source) + +**Duration & easing consistency**: Use a consistent duration band across related interactions. Prefer ≤3 different `easing` strings per config to maintain visual coherence. + +**Effect reuse**: When the same effect definition is used across multiple interactions, define it once in the top-level `effects` registry and reference it via `effectId`. This improves maintainability and reduces config size. + +**Preset naming affinity** — choose presets that match the trigger type for semantic clarity: + +- `viewEnter` → Entrance presets ending in `In` (e.g. `FadeIn`, `SlideIn`, `GlideIn`) +- `viewProgress` → Scroll presets ending in `Scroll` (e.g. `ParallaxScroll`, `FadeScroll`, `RevealScroll`) +- `pointerMove` → Mouse presets ending in `Mouse` (e.g. `TrackMouse`, `Tilt3DMouse`, `ScaleMouse`) +- `hover`/`click` → Ongoing presets (e.g. `Pulse`, `Bounce`, `Wiggle`) or Entrance presets (e.g. `FadeIn`) + +--- + ## Static API -| Method / Property | Description | -| :---------------------------------- | :------------------------------------------------------------------------------------------------------------ | -| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | -| `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `create`. | -| `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | -| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | -| `Interact.allowA11yTriggers` | `boolean` (default: `false`) — enable accessibility trigger variants (`interest`, `activate`). | -| `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | +| Method / Property | Description | +| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | +| `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `create`. | +| `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | +| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | +| `Interact.allowA11yTriggers` | `boolean` (default: `false`) — **MUST set to `true`** when using `interest` or `activate` triggers. Required for a11y pairing to work. | +| `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | **`Interact.setup(options)`** — optional configuration object: diff --git a/packages/interact/rules/hover.md b/packages/interact/rules/hover.md index f2f5d3b6..9fcfb2fe 100644 --- a/packages/interact/rules/hover.md +++ b/packages/interact/rules/hover.md @@ -175,7 +175,7 @@ Use sequences when a hover should sync/stagger animations across multiple elemen offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -188,4 +188,4 @@ Use sequences when a hover should sync/stagger animations across multiple elemen - `[SOURCE_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset for staggering each child's animation start, in milliseconds. - `[OFFSET_EASING]` — easing curve for the offset staggering distribution. CSS easing string, or named easing from `@wix/motion`. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of or a reference to a time-based animation effect. diff --git a/packages/interact/rules/pointermove.md b/packages/interact/rules/pointermove.md index d471ea29..06f347eb 100644 --- a/packages/interact/rules/pointermove.md +++ b/packages/interact/rules/pointermove.md @@ -69,7 +69,7 @@ type Progress = { Controls which element's bounds define the 0–1 progress range. - **`false` (default)**: Progress is calculated against the **source element's** (or viewport's) bounds. The `50%` progress of the timeline is at the center of the source element. -- **`true`**: `50%` progress of the timeline is calculated against the **target element's center**. The edges of the timeline are still calculated against the edges of the source element/viewport depending on `hitAea`. +- **`true`**: `50%` progress of the timeline is calculated against the **target element's center**. The edges of the timeline are still calculated against the edges of the source element/viewport depending on `hitArea`. --- diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index e6cf1171..6e758d92 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -91,6 +91,14 @@ const css = generate(config); - Do NOT use `initial` for `viewEnter` with `triggerType: 'repeat'`/`'alternate'`/`'state'`. For those, manually apply the initial keyframe as inline styles on the target element and use `fill: 'both'`. - If other interactions in the config also need FOUC prevention, `generate(config)` covers them all — set `initial` only on the relevant `viewEnter` + `triggerType: 'once'` elements. +## Common Anti-patterns + +- **Duplicate key+trigger**: Do NOT define two interactions with the same `key` and `trigger` — they will shadow each other. +- **`viewEnter` + `repeat` without `threshold`**: When using `triggerType: 'repeat'`, ALWAYS set a `threshold` in `params` (e.g. `threshold: 0.3`) to control when the re-trigger fires. Without it, a tiny pixel entering/leaving can cause rapid re-triggers. +- **Missing FOUC prevention**: Every `viewEnter` + `triggerType: 'once'` (same element) entrance animation MUST have both `generate(config)` CSS AND `initial` on the element. + +--- + ## Rule 1: keyframeEffect / namedEffect (TimeEffect) Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animation (CSS or WAAPI). Set `triggerType` on each effect to control playback behavior. Use `params` only for observer configuration (`threshold`, `inset`). @@ -207,7 +215,7 @@ Use sequences when a viewEnter should sync/stagger animations across multiple el offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -221,4 +229,4 @@ Use sequences when a viewEnter should sync/stagger animations across multiple el - `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset between each child's animation start, in milliseconds. - `[OFFSET_EASING]` — CSS easing or named easing from `@wix/motion`, for the stagger distribution. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of or a reference to a time-based animation effect. diff --git a/packages/interact/rules/viewprogress.md b/packages/interact/rules/viewprogress.md index a5758e30..7e59d0f9 100644 --- a/packages/interact/rules/viewprogress.md +++ b/packages/interact/rules/viewprogress.md @@ -6,6 +6,8 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View **Offset semantics:** The `offset` inside `rangeStart`/`rangeEnd` is an object `{ unit: 'percentage', value: NUMBER }` where value is 0–100. For absolute lengths use `{ unit: 'px', value: NUMBER }` (or other CSS length units). Positive values move the effective range boundary forward along the scroll axis. +> **Range order**: `rangeStart.name` MUST come before `rangeEnd.name` in scroll order. The correct scroll order is: `entry` → `entry-crossing` → `contain` → `exit-crossing` → `exit` → `cover`. Using an inverted range (e.g. `rangeStart: { name: 'exit' }` with `rangeEnd: { name: 'entry' }`) will break the animation. + ## Table of Contents - [Rule 1: ViewProgress with keyframeEffect or namedEffect](#rule-1-viewprogress-with-keyframeeffect-or-namedeffect) diff --git a/yarn.lock b/yarn.lock index 1fad1391..99de8d9c 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.1, @wix/interact@workspace:packages/interact": +"@wix/interact@npm:^2.2.1, @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"