mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(plugins): pin Motion React UMD loader in plugins, generalize the system-prompt rule PR #3973 patched the blank-screen crash (Motion React hooks loaded from the vanilla motion.js DOM bundle) at the runtime/write boundary and added a Framer Motion note to the official system prompt. This closes the remaining gaps: - Root cause was in the plugins, not just the runtime: the 25-plugin motionsites batch (#3873) prescribes Framer Motion hooks in the React port but never locks how to load them, exactly as it locks fonts/colors/asset URLs. Add a "Motion loading (locked)" note to the 8 plugins that genuinely prescribe Framer Motion (3d-creator-portfolio, acreage-farming, evergreen-finance, liquid-glass-agency, luxury-botanical, mindloop-landing, portfolio-cosmic, shamoni). Plugins that map useScroll *down* to a passive listener, or use GSAP only (cinematic-landing-page), are intentionally left untouched. - Generalize the system-prompt note to lead with the principle (two UMD builds: vanilla DOM vs React, hooks live only on the React build's window.Motion) while keeping one pinned example version. - Add port-path regression coverage for the reported luxury-botanical plugin: full Motion hook set (useScroll/useTransform/useAnimationFrame/useMotionValue), the minified vanilla bundle, and the wrong-bundle + FramerMotion-global combo. Validation: pnpm guard, contracts + daemon typecheck, daemon artifact-runtime-compat.test.ts (8/8). * fix(daemon): widen Motion UMD normalizer to jsdelivr + wrong-file shape The runtime normalizer only matched the exact unpkg `motion@VER/dist/motion.js` script shape, so two real agent outputs still slipped through to a blank screen: a vanilla bundle served from jsdelivr, and the right package name pointing at the wrong file (`framer-motion@VER/dist/motion.js`). Widen the matcher to both hosts and both package spellings, preserve the original CDN host on rewrite, and document the `<script src>`-only boundary (ESM imports / importmaps / esm.sh are steered by the prompt + plugin notes, not rewritten). The React-hook usage gate still keeps animate-only vanilla DOM artifacts untouched, and a new test pins that the correct framer-motion.js bundle is never falsely rewritten. --------- Co-authored-by: audit <a@b.c>
165 lines
7.7 KiB
TypeScript
165 lines
7.7 KiB
TypeScript
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { normalizeArtifactRuntimeImports } from '../src/artifact-runtime-compat.js';
|
|
import { writeProjectFile } from '../src/projects.js';
|
|
|
|
const brokenReactMotionHtml = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<script src="https://unpkg.com/motion@11.11.13/dist/motion.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="text/babel">
|
|
const { motion, useScroll, useTransform } = Motion;
|
|
function App() {
|
|
const { scrollYProgress } = useScroll();
|
|
const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
|
|
return <motion.div style={{ opacity }}>Hello</motion.div>;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
describe('artifact runtime compatibility normalizer', () => {
|
|
it('rewrites vanilla Motion UMD to Framer Motion UMD when React Motion hooks are used', () => {
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', brokenReactMotionHtml);
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('https://unpkg.com/motion@11.11.13/dist/motion.js');
|
|
});
|
|
|
|
it('preserves non-HTML files and HTML that does not use Motion React hooks', () => {
|
|
expect(normalizeArtifactRuntimeImports('notes.md', brokenReactMotionHtml)).toBe(brokenReactMotionHtml);
|
|
|
|
const vanillaMotionHtml = '<!doctype html><script src="https://unpkg.com/motion@11.11.13/dist/motion.js"></script><script>Motion.animate("div", { opacity: 1 })</script>';
|
|
expect(normalizeArtifactRuntimeImports('animation.html', vanillaMotionHtml)).toBe(vanillaMotionHtml);
|
|
});
|
|
|
|
it('removes stale integrity from rewritten script tags', () => {
|
|
const html = brokenReactMotionHtml.replace(
|
|
'src="https://unpkg.com/motion@11.11.13/dist/motion.js"',
|
|
'src="https://unpkg.com/motion@11.11.13/dist/motion.js" integrity="sha384-stale"',
|
|
);
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('sha384-stale');
|
|
});
|
|
|
|
it('aliases the FramerMotion global for framer-motion UMD artifacts', () => {
|
|
const html = `<!doctype html>
|
|
<script src="https://unpkg.com/framer-motion@11.11.17/dist/framer-motion.js"></script>
|
|
<script type="text/babel">const { motion, useScroll } = FramerMotion;</script>`;
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('window.FramerMotion = window.FramerMotion || window.Motion;');
|
|
expect(normalized.indexOf('framer-motion@11.11.17')).toBeLessThan(normalized.indexOf('window.FramerMotion'));
|
|
});
|
|
|
|
it('normalizes content before writeProjectFile persists it', async () => {
|
|
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-runtime-compat-'));
|
|
try {
|
|
await writeProjectFile(projectsRoot, 'project-1', 'landing.html', Buffer.from(brokenReactMotionHtml, 'utf8'));
|
|
|
|
const saved = await readFile(path.join(projectsRoot, 'project-1', 'landing.html'), 'utf8');
|
|
expect(saved).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(saved).not.toContain('https://unpkg.com/motion@11.11.13/dist/motion.js');
|
|
} finally {
|
|
await rm(projectsRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Port-path regression coverage for the reported luxury-botanical plugin. Its React port leans on
|
|
// the full Motion hook set (useScroll/useTransform/useAnimationFrame/useMotionValue), which only the
|
|
// framer-motion React UMD build exposes. A single-file prototype that reaches for the vanilla
|
|
// motion.js DOM bundle throws `useScroll is not a function` and renders blank — this is the artifact
|
|
// the normalizer must repair at the write boundary.
|
|
const luxuryBotanicalOrbitHtml = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<script src="https://unpkg.com/motion@11.11.13/dist/motion.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="text/babel">
|
|
const { motion, useScroll, useTransform, useAnimationFrame, useMotionValue } = Motion;
|
|
function OrbitImages() {
|
|
const { scrollYProgress } = useScroll({ offset: ['start start', 'end end'] });
|
|
const progress = useMotionValue(0);
|
|
const radius = useTransform(scrollYProgress, [0.15, 0.25], [330, 650]);
|
|
useAnimationFrame(() => progress.set(progress.get() + 0.4));
|
|
return <motion.div style={{ '--r': radius }} />;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
it('repairs the luxury-botanical orbit prototype that uses the full Motion hook set', () => {
|
|
const normalized = normalizeArtifactRuntimeImports('beyond-the-collection.html', luxuryBotanicalOrbitHtml) as string;
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('/dist/motion.js');
|
|
// The destructured `Motion` global now resolves to the React build that actually carries the hooks.
|
|
expect(normalized).toContain('useAnimationFrame');
|
|
});
|
|
|
|
it('rewrites the minified vanilla Motion bundle as well', () => {
|
|
const html = brokenReactMotionHtml.replace('dist/motion.js', 'dist/motion.min.js');
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('/dist/motion.min.js');
|
|
});
|
|
|
|
it('repairs a prototype that loads the wrong bundle and reads the FramerMotion global', () => {
|
|
const html = brokenReactMotionHtml.replace('const { motion, useScroll, useTransform } = Motion;', 'const { motion, useScroll, useTransform } = FramerMotion;');
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('/dist/motion.js');
|
|
expect(normalized).toContain('window.FramerMotion = window.FramerMotion || window.Motion;');
|
|
});
|
|
|
|
it('repairs the right package name but wrong file (framer-motion@.../dist/motion.js)', () => {
|
|
const html = brokenReactMotionHtml.replace(
|
|
'https://unpkg.com/motion@11.11.13/dist/motion.js',
|
|
'https://unpkg.com/framer-motion@11.11.13/dist/motion.js',
|
|
);
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('/dist/motion.js');
|
|
});
|
|
|
|
it('repairs the vanilla bundle served from jsdelivr and preserves the host', () => {
|
|
const html = brokenReactMotionHtml.replace(
|
|
'https://unpkg.com/motion@11.11.13/dist/motion.js',
|
|
'https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js',
|
|
);
|
|
|
|
const normalized = normalizeArtifactRuntimeImports('landing.html', html) as string;
|
|
|
|
expect(normalized).toContain('https://cdn.jsdelivr.net/npm/framer-motion@11.11.13/dist/framer-motion.js');
|
|
expect(normalized).not.toContain('/dist/motion.js');
|
|
});
|
|
|
|
it('leaves the correct framer-motion.js bundle untouched (no false rewrite)', () => {
|
|
const html = brokenReactMotionHtml.replace(
|
|
'https://unpkg.com/motion@11.11.13/dist/motion.js',
|
|
'https://unpkg.com/framer-motion@11.11.13/dist/framer-motion.js',
|
|
);
|
|
|
|
// Already correct → returned verbatim (idempotent), the vanilla-file matcher must not touch it.
|
|
expect(normalizeArtifactRuntimeImports('landing.html', html)).toBe(html);
|
|
});
|
|
});
|