diff --git a/bun.lock b/bun.lock index 6c432fe596e..d8d4c36c6d6 100644 --- a/bun.lock +++ b/bun.lock @@ -1820,7 +1820,9 @@ "zod": "catalog:", }, "devDependencies": { + "@happy-dom/global-registrator": "14.5.1", "@remotion/eslint-config-internal": "workspace:*", + "@testing-library/react": "16.1.0", "@types/semver": "7.5.3", "@typescript/native-preview": "catalog:", "eslint": "catalog:", diff --git a/packages/brand/src/CanvasCaptureAnnouncement/CanvasCaptureAnnouncement.tsx b/packages/brand/src/CanvasCaptureAnnouncement/CanvasCaptureAnnouncement.tsx new file mode 100644 index 00000000000..dc6b22082a0 --- /dev/null +++ b/packages/brand/src/CanvasCaptureAnnouncement/CanvasCaptureAnnouncement.tsx @@ -0,0 +1,132 @@ +import {Video} from '@remotion/media'; +import {TransitionSeries} from '@remotion/transitions'; +import {AbsoluteFill} from 'remotion'; + +export const CanvasCaptureAnnouncement: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/brand/src/CanvasCaptureShort/CanvasCaptureShort.tsx b/packages/brand/src/CanvasCaptureShort/CanvasCaptureShort.tsx new file mode 100644 index 00000000000..1dbe5de7d3a --- /dev/null +++ b/packages/brand/src/CanvasCaptureShort/CanvasCaptureShort.tsx @@ -0,0 +1,114 @@ +import {Video} from '@remotion/media'; +import {TransitionSeries} from '@remotion/transitions'; +import {AbsoluteFill} from 'remotion'; + +export const CanvasCaptureShort: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/brand/src/Root.tsx b/packages/brand/src/Root.tsx index 9c9afa6faec..a8db11d394d 100644 --- a/packages/brand/src/Root.tsx +++ b/packages/brand/src/Root.tsx @@ -15,7 +15,9 @@ import { import {Banner} from './Brand/Banner'; import {Comp} from './Brand/Composition'; import {TriangleDemo} from './Brand/TriangleToSquare'; +import {CanvasCaptureAnnouncement} from './CanvasCaptureAnnouncement/CanvasCaptureAnnouncement'; import {CanvasCaptureComposition} from './CanvasCapturePreview'; +import {CanvasCaptureShort} from './CanvasCaptureShort/CanvasCaptureShort'; import {Checker} from './Checker'; import {CloseUp1} from './CloseUp1'; import {CloseUp2} from './CloseUp2'; @@ -173,6 +175,24 @@ export const RemotionRoot: React.FC = () => { height={520} /> + + + + window.__browserStudioProject.files[ '/project/src/Composition.tsx' - ].includes("staticFile('framer.webm')"), + ].includes('staticFile("framer.webm")'), ), ) .toBe(true); diff --git a/packages/browser-studio/src/browser-studio-operations.ts b/packages/browser-studio/src/browser-studio-operations.ts index 336595cc019..f770bc031b1 100644 --- a/packages/browser-studio/src/browser-studio-operations.ts +++ b/packages/browser-studio/src/browser-studio-operations.ts @@ -1275,7 +1275,6 @@ export const createBrowserStudioOperations = ({ nodePathMutationFiles: updates.map(({fileName, result}) => ({ absolutePath: fileName, remappings: result.nodePathRemappings, - restoredNodePaths: [], })), }); if (nodePathMutation === null) { @@ -1340,7 +1339,6 @@ export const createBrowserStudioOperations = ({ nodePathMutationFiles: updates.map(({fileName, result}) => ({ absolutePath: fileName, remappings: result.nodePathRemappings, - restoredNodePaths: [], })), }); if (nodePathMutation === null) { @@ -1382,7 +1380,6 @@ export const createBrowserStudioOperations = ({ { absolutePath, remappings: result.nodePathRemappings, - restoredNodePaths: [], }, ], }); @@ -1500,7 +1497,6 @@ export const createBrowserStudioOperations = ({ { absolutePath, remappings: result.nodePathRemappings, - restoredNodePaths: [], }, ], }); @@ -1873,7 +1869,6 @@ export const createBrowserStudioOperations = ({ { absolutePath, remappings: result.nodePathRemappings, - restoredNodePaths: [], }, ], }); @@ -1922,7 +1917,6 @@ export const createBrowserStudioOperations = ({ { absolutePath: result.filePath, remappings: result.nodePathRemappings, - restoredNodePaths: [], }, ], }); @@ -2106,7 +2100,6 @@ export const createBrowserStudioOperations = ({ { absolutePath: insertion.filePath, remappings: insertion.nodePathRemappings, - restoredNodePaths: [], }, ], }); diff --git a/packages/browser-studio/src/browser-studio-project-controller.ts b/packages/browser-studio/src/browser-studio-project-controller.ts index 959cf2c4a21..ed8ae6548ff 100644 --- a/packages/browser-studio/src/browser-studio-project-controller.ts +++ b/packages/browser-studio/src/browser-studio-project-controller.ts @@ -7,7 +7,6 @@ import type { SequenceNodePathRemapping, UndoResponse, } from '@remotion/studio-shared'; -import type {SequenceNodePath} from 'remotion'; import { collectBrowserStudioProjectStorageGarbage, createBrowserStudioProjectStorage, @@ -565,20 +564,11 @@ export const createBrowserStudioProjectController = ({ redoStack.push(entry); const files = entry.nodePathMutationFiles?.map((file) => ({ absolutePath: file.absolutePath, - remappings: file.remappings.flatMap( - (remapping): SequenceNodePathRemapping[] => - remapping.newNodePath === null - ? [] - : [ - { - oldNodePath: remapping.newNodePath, - newNodePath: remapping.oldNodePath, - }, - ], - ), - restoredNodePaths: file.remappings.flatMap( - (remapping): SequenceNodePath[] => - remapping.newNodePath === null ? [remapping.oldNodePath] : [], + remappings: file.remappings.map( + (remapping): SequenceNodePathRemapping => ({ + oldNodePath: remapping.newNodePath, + newNodePath: remapping.oldNodePath, + }), ), })); const nodePathMutation = commitProject({ diff --git a/packages/browser-studio/src/test/browser-studio-operations.test.ts b/packages/browser-studio/src/test/browser-studio-operations.test.ts index 91433255327..7b6eac8ada6 100644 --- a/packages/browser-studio/src/test/browser-studio-operations.test.ts +++ b/packages/browser-studio/src/test/browser-studio-operations.test.ts @@ -216,9 +216,12 @@ registerRoot(Root); ); expect(output).toContain(''); expect(output).toContain(' ({ + absolutePath: file.absolutePath, + remappings: file.remappings.map((remapping) => ({ + oldNodePath: remapping.newNodePath, + newNodePath: remapping.oldNodePath, + })), + })), ); expect(project.files['/project/src/Composition.tsx']).toBe( initialProject.files['/project/src/Composition.tsx'], @@ -135,7 +141,6 @@ test('mutates virtual files, emits events, and preserves undo and redo history', newNodePath: null, }, ]), - restoredNodePaths: [], }, ]); const undoDeleteResult = await undo(); @@ -455,7 +460,7 @@ export const LowerThird = () => ; element.sourceCode, ); expect(project.files['/project/src/Composition.tsx']).toContain( - "import {LowerThird} from './lower-third.element';", + 'import { LowerThird } from "./lower-third.element";', ); expect(project.files['/project/src/Composition.tsx']).toContain(' ; 'name="Lower Third"', ); expect(project.files['/project/src/Composition.tsx']).toContain( - "translate: '24px 48px'", + 'translate: "24px 48px"', ); const packageJson = JSON.parse(project.files['/project/package.json']) as { dependencies: Record; @@ -582,7 +587,7 @@ test('inserts generic elements with pinned Remotion dependencies', async () => { } expect(project.files['/project/src/Composition.tsx']).toContain( - "from '@remotion/media'", + 'from "@remotion/media"', ); expect(project.files['/project/src/Composition.tsx']).toContain('; + readonly style: AudioOscilloscopeProps['style']; + readonly windowInSeconds: number; +}> = ({ + amplitude, + audioSrc, + lineColor, + lineWidth, + outlineRef, + style, + windowInSeconds, +}) => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const {audioData, dataOffsetInSeconds} = useWindowedAudioData({ + fps, + frame, + src: audioSrc, + windowInSeconds: 10, + }); + const waveform = audioData + ? getWaveformPortion({ + audioData, + channel: 0, + dataOffsetInSeconds, + durationInSeconds: windowInSeconds, + normalize: false, + numberOfSamples: 128, + outputRange: 'minus-one-to-one', + startTimeInSeconds: frame / fps - windowInSeconds / 2, + }).map((sample) => sample.amplitude) + : []; + const path = createSmoothSvgPath({ + points: waveform.map((value, index) => ({ + x: + lineWidth * 2 + (index / (waveform.length - 1)) * (900 - lineWidth * 4), + y: 150 + value * 150 * amplitude, + })), + }); + + return ( +
+
+ ); +}; + const AudioOscilloscopeInner = forwardRef< HTMLDivElement, AudioOscilloscopeProps & {readonly controls: SequenceControls | undefined} @@ -86,35 +179,7 @@ const AudioOscilloscopeInner = forwardRef< }, ref, ) => { - const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); const outlineRef = useRef(null); - const {audioData, dataOffsetInSeconds} = useWindowedAudioData({ - fps, - frame, - src: audioSrc, - windowInSeconds: 10, - }); - const waveform = audioData - ? getWaveformPortion({ - audioData, - channel: 0, - dataOffsetInSeconds, - durationInSeconds: windowInSeconds, - normalize: false, - numberOfSamples: 128, - outputRange: 'minus-one-to-one', - startTimeInSeconds: frame / fps - windowInSeconds / 2, - }).map((sample) => sample.amplitude) - : []; - const path = createSmoothSvgPath({ - points: waveform.map((value, index) => ({ - x: - lineWidth * 2 + - (index / (waveform.length - 1)) * (900 - lineWidth * 4), - y: 150 + value * 150 * amplitude, - })), - }); useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []); @@ -126,50 +191,15 @@ const AudioOscilloscopeInner = forwardRef< name={name ?? 'Audio oscilloscope'} outlineRef={outlineRef} > -
-
+ ); }, diff --git a/packages/docs/prewarm-twoslash.ts b/packages/docs/prewarm-twoslash.ts index 86f4b7629cd..3923074d4d6 100644 --- a/packages/docs/prewarm-twoslash.ts +++ b/packages/docs/prewarm-twoslash.ts @@ -8,11 +8,9 @@ import {createInterface} from 'readline'; import {Glob} from 'bun'; import { createTwoslashCacheContext, - garbageCollectSharedTwoslashCache, getTwoslashCacheKey, getTwoslashLocalCachePath, getTwoslashVersions, - publishLocalTwoslashCacheEntry, readTwoslashCacheEntry, } from '../docusaurus-plugin/src/twoslash-cache'; import {isTwoslashEnabled} from '../docusaurus-plugin/src/twoslash-enabled'; @@ -111,17 +109,6 @@ function computeCacheLocation( return {key, path: getTwoslashLocalCachePath(cacheContext, key)}; } -function publishLocalCacheEntries(validCachePaths: Set): void { - for (const cachePath of validCachePaths) { - publishLocalTwoslashCacheEntry({ - context: cacheContext, - key: basename(cachePath, '.json'), - }); - } - - garbageCollectSharedTwoslashCache(cacheContext); -} - function addIncludes( map: Map, name: string, @@ -421,7 +408,6 @@ async function main() { const uncachedBlocks = [...uniqueBlocks.values()]; if (uncachedBlocks.length === 0) { - publishLocalCacheEntries(validCachePaths); const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); console.log(`All twoslash blocks are cached (${elapsed}s to scan)`); return; @@ -603,9 +589,14 @@ async function main() { if (unfinished.length > 0) { const requeue: TwoslashBlock[] = []; for (const item of unfinished) { - // Workers publish cache files using an atomic rename, so an existing - // final path is complete even when the completion message was lost. - if (existsSync(item.cachePath)) { + // Workers publish cache files using an atomic rename, so a valid final + // entry is complete even when the completion message was lost. + if ( + readTwoslashCacheEntry({ + context: cacheContext, + key: basename(item.cachePath, '.json'), + }) !== null + ) { recordResult({cachePath: item.cachePath, ms: 0}); continue; } @@ -792,7 +783,6 @@ async function main() { process.exit(1); } - publishLocalCacheEntries(validCachePaths); process.exit(0); } diff --git a/packages/docusaurus-plugin/src/caching.ts b/packages/docusaurus-plugin/src/caching.ts index 4f811a2d898..2eb0a47f7fb 100644 --- a/packages/docusaurus-plugin/src/caching.ts +++ b/packages/docusaurus-plugin/src/caching.ts @@ -41,8 +41,7 @@ const getCacheContext = () => { }; /** - * Keeps a local cache of the final HTML and shares immutable entries with the - * other worktrees belonging to the same Git repository. + * Keeps a local cache of the final HTML in node_modules/.cache/twoslash. */ export const cachedTwoslashCall = ( code: string, diff --git a/packages/docusaurus-plugin/src/twoslash-cache.test.ts b/packages/docusaurus-plugin/src/twoslash-cache.test.ts index 8f1527ac510..fba061d371f 100644 --- a/packages/docusaurus-plugin/src/twoslash-cache.test.ts +++ b/packages/docusaurus-plugin/src/twoslash-cache.test.ts @@ -1,24 +1,14 @@ import {afterEach, describe, expect, test} from 'bun:test'; import {execFileSync} from 'child_process'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - utimesSync, - writeFileSync, -} from 'fs'; +import {mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'fs'; import {tmpdir} from 'os'; import {join} from 'path'; import {pathToFileURL} from 'url'; import { createTwoslashCacheContext, - garbageCollectSharedTwoslashCache, - getSharedTwoslashCacheRoot, getTwoslashCacheKey, getTwoslashEnvironmentHash, + getTwoslashLocalCachePath, readTwoslashCacheEntry, type TwoslashCacheContext, writeTwoslashCacheEntry, @@ -26,20 +16,17 @@ import { const temporaryDirectories: string[] = []; -const makeContext = ( - root: string, - localName: string, - environmentHash = 'environment-a', -): TwoslashCacheContext => ({ - localRoot: join(root, localName), - sharedRoot: join(root, 'shared'), - environmentHash, - versions: { - twoslash: '1.0.0', - shiki: '1.0.0', - typescript: '1.0.0', - shikiTwoslash: '1.0.0', - }, +const versions = { + twoslash: '1.0.0', + shiki: '1.0.0', + typescript: '1.0.0', + shikiTwoslash: '1.0.0', +}; + +const makeContext = (root: string): TwoslashCacheContext => ({ + localRoot: join(root, 'node_modules', '.cache', 'twoslash'), + environmentHash: 'environment-a', + versions, workspacePackages: {}, }); @@ -49,20 +36,15 @@ const makeTemporaryDirectory = () => { return directory; }; -const versions = { - twoslash: '1.0.0', - shiki: '1.0.0', - typescript: '1.0.0', - shikiTwoslash: '1.0.0', -}; - const makeEnvironmentRepository = ({ - lockfile = 'lockfile', + committedLockfile = 'lockfile', + installedLockfile, name, packages, root, }: { - lockfile?: string; + committedLockfile?: string; + installedLockfile?: string; name: string; packages: Record< string, @@ -73,7 +55,7 @@ const makeEnvironmentRepository = ({ const repository = join(root, name); const docsRoot = join(repository, 'packages', 'docs'); mkdirSync(docsRoot, {recursive: true}); - writeFileSync(join(repository, 'bun.lock'), lockfile, 'utf8'); + writeFileSync(join(repository, 'bun.lock'), committedLockfile, 'utf8'); writeFileSync(join(repository, 'package.json'), '{}', 'utf8'); writeFileSync( join(docsRoot, 'package.json'), @@ -102,6 +84,23 @@ const makeEnvironmentRepository = ({ execFileSync('git', ['init', '--quiet', repository]); execFileSync('git', ['-C', repository, 'add', '.']); + execFileSync('git', [ + '-C', + repository, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '-m', + 'Initial', + ]); + + if (installedLockfile !== undefined) { + writeFileSync(join(repository, 'bun.lock'), installedLockfile, 'utf8'); + } + return docsRoot; }; @@ -112,9 +111,101 @@ afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, {recursive: true, force: true}); } +}); + +describe('Twoslash local cache', () => { + test('reuses unchanged snippets and invalidates only a changed snippet', () => { + const context = makeContext(makeTemporaryDirectory()); + const firstCode = 'const first = 1;'; + const secondCode = 'const second = 2;'; + const firstKey = getTwoslashCacheKey({ + code: firstCode, + context, + lang: 'ts', + }); + const secondKey = getTwoslashCacheKey({ + code: secondCode, + context, + lang: 'ts', + }); + + writeTwoslashCacheEntry({ + context, + html: '
first
', + key: firstKey, + }); + writeTwoslashCacheEntry({ + context, + html: '
second
', + key: secondKey, + }); + + expect(readTwoslashCacheEntry({context, key: firstKey})).toBe( + '
first
', + ); + const changedFirstKey = getTwoslashCacheKey({ + code: `${firstCode}\nconst changed = true;`, + context, + lang: 'ts', + }); + expect(readTwoslashCacheEntry({context, key: changedFirstKey})).toBeNull(); + expect(readTwoslashCacheEntry({context, key: secondKey})).toBe( + '
second
', + ); + }); + + test('publishes local entries atomically and rejects empty entries', async () => { + const root = makeTemporaryDirectory(); + const context = makeContext(root); + const key = getTwoslashCacheKey({ + code: 'const value = 1;', + context, + lang: 'ts', + }); + const candidates = Array.from( + {length: 6}, + (_, index) => `
worker-${index}
`, + ); + const moduleUrl = pathToFileURL(join(__dirname, 'twoslash-cache.ts')).href; + const script = ` + import {writeTwoslashCacheEntry} from ${JSON.stringify(moduleUrl)}; + writeTwoslashCacheEntry({ + context: JSON.parse(process.env.TWOSLASH_TEST_CONTEXT), + key: process.env.TWOSLASH_TEST_KEY, + html: process.env.TWOSLASH_TEST_HTML, + }); + `; + const processes = candidates.map((html) => + Bun.spawn({ + cmd: [process.execPath, '-e', script], + env: { + ...process.env, + TWOSLASH_TEST_CONTEXT: JSON.stringify(context), + TWOSLASH_TEST_KEY: key, + TWOSLASH_TEST_HTML: html, + }, + stderr: 'pipe', + stdout: 'ignore', + }), + ); + expect( + await Promise.all(processes.map((process) => process.exited)), + ).toEqual(candidates.map(() => 0)); - delete process.env.TWOSLASH_SHARED_CACHE_GC_INTERVAL_MS; - delete process.env.TWOSLASH_SHARED_CACHE_MAX_AGE_MS; + const cached = readTwoslashCacheEntry({context, key}); + if (cached === null) { + throw new Error('Expected a local cache entry'); + } + + expect(candidates).toContain(cached); + expect( + readdirSync(context.localRoot).filter((file) => file.endsWith('.tmp')), + ).toEqual([]); + + const emptyKey = 'empty'; + writeFileSync(getTwoslashLocalCachePath(context, emptyKey), '', 'utf8'); + expect(readTwoslashCacheEntry({context, key: emptyKey})).toBeNull(); + }); }); describe('Twoslash cache keys', () => { @@ -167,63 +258,14 @@ describe('Twoslash cache keys', () => { }), ); const alphaCode = "import {alpha} from '@remotion/alpha';\nalpha;"; - const alphaKey = getTwoslashCacheKey({ - code: alphaCode, - context: baseline, - lang: 'ts', - }); const getAlphaKey = (context: TwoslashCacheContext) => getTwoslashCacheKey({code: alphaCode, context, lang: 'ts'}); + const alphaKey = getAlphaKey(baseline); expect(getAlphaKey(unrelatedChange)).toBe(alphaKey); expect(getAlphaKey(directChange)).not.toBe(alphaKey); expect(getAlphaKey(transitiveChange)).not.toBe(alphaKey); - const sharedRoot = join(root, 'selective-shared-cache'); - const baselineCache = { - ...baseline, - localRoot: join(root, 'baseline-cache'), - sharedRoot, - }; - writeTwoslashCacheEntry({ - context: baselineCache, - html: '
alpha
', - key: alphaKey, - }); - expect( - readTwoslashCacheEntry({ - context: { - ...unrelatedChange, - localRoot: join(root, 'unrelated-cache'), - sharedRoot, - }, - key: getAlphaKey(unrelatedChange), - }), - ).toBe('
alpha
'); - expect( - readTwoslashCacheEntry({ - context: { - ...directChange, - localRoot: join(root, 'direct-cache'), - sharedRoot, - }, - key: getAlphaKey(directChange), - }), - ).toBeNull(); - - for (const code of [ - "import type {Alpha} from '@remotion/alpha/subpath';", - "import '@remotion/alpha';", - "export {alpha} from '@remotion/alpha';", - "import('@remotion/alpha');", - "require('@remotion/alpha');", - '/// ', - ]) { - expect( - getTwoslashCacheKey({code, context: directChange, lang: 'ts'}), - ).not.toBe(getTwoslashCacheKey({code, context: baseline, lang: 'ts'})); - } - expect( getTwoslashCacheKey({ code: "import {beta} from '@remotion/beta';\nbeta;", @@ -252,226 +294,83 @@ describe('Twoslash cache keys', () => { ); }); - test('fingerprint external dependency resolutions globally', () => { + test('uses the committed lockfile after a Vercel-style install', () => { const root = makeTemporaryDirectory(); - const first = makeEnvironmentRepository({ - name: 'first', + const checkout = makeEnvironmentRepository({ + name: 'checkout', packages: {}, root, }); - const second = makeEnvironmentRepository({ - lockfile: 'updated lockfile', - name: 'second', + const installedCheckout = makeEnvironmentRepository({ + installedLockfile: 'lockfile rewritten during install', + name: 'installed-checkout', packages: {}, root, }); - - expect(getTwoslashEnvironmentHash(second)).not.toBe( - getTwoslashEnvironmentHash(first), - ); - }); - - test('include the language and type environment', () => { - const root = makeTemporaryDirectory(); - const context = makeContext(root, 'local-a'); - const base = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context, - }); - const differentLanguage = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'tsx', - context, - }); - const differentEnvironment = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context: makeContext(root, 'local-b', 'environment-b'), - }); - const differentVersions = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context: { - ...context, - versions: {...context.versions, shikiTwoslash: '2.0.0'}, - }, + const updatedCheckout = makeEnvironmentRepository({ + committedLockfile: 'committed dependency update', + name: 'updated-checkout', + packages: {}, + root, }); - expect(differentLanguage).not.toBe(base); - expect(differentEnvironment).not.toBe(base); - expect(differentVersions).not.toBe(base); - }); -}); - -describe('shared Twoslash cache', () => { - test('uses the same root for all Git worktrees', () => { - const root = makeTemporaryDirectory(); - const repository = join(root, 'repository'); - const worktree = join(root, 'worktree'); - mkdirSync(repository); - execFileSync('git', ['init', '--quiet', repository]); - execFileSync('git', [ - '-C', - repository, - 'config', - 'user.email', - 'test@example.com', - ]); - execFileSync('git', ['-C', repository, 'config', 'user.name', 'Test']); - writeFileSync(join(repository, 'file'), 'content', 'utf8'); - execFileSync('git', ['-C', repository, 'add', 'file']); - execFileSync('git', [ - '-C', - repository, - 'commit', - '--quiet', - '-m', - 'Initial', - ]); - execFileSync('git', [ - '-C', - repository, - 'worktree', - 'add', - '--quiet', - '--detach', - worktree, - ]); - - expect(getSharedTwoslashCacheRoot(worktree)).toBe( - getSharedTwoslashCacheRoot(repository), + expect(getTwoslashEnvironmentHash(installedCheckout)).toBe( + getTwoslashEnvironmentHash(checkout), ); - }); - - test('hydrates another worktree and does not replace immutable entries', () => { - const root = makeTemporaryDirectory(); - const first = makeContext(root, 'local-a'); - const second = makeContext(root, 'local-b'); - const third = makeContext(root, 'local-c'); - const key = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context: first, - }); - - writeTwoslashCacheEntry({context: first, key, html: '
first
'}); - expect(readTwoslashCacheEntry({context: second, key})).toBe( - '
first
', + expect(getTwoslashEnvironmentHash(updatedCheckout)).not.toBe( + getTwoslashEnvironmentHash(checkout), ); - - writeTwoslashCacheEntry({context: second, key, html: '
second
'}); - expect(readTwoslashCacheEntry({context: third, key})).toBe( - '
first
', + const code = 'const value = 1;'; + expect( + getTwoslashCacheKey({ + code, + context: makeRepositoryContext(installedCheckout), + lang: 'ts', + }), + ).toBe( + getTwoslashCacheKey({ + code, + context: makeRepositoryContext(checkout), + lang: 'ts', + }), ); }); - test('publishes one complete entry when processes race', async () => { + test('include the language and Twoslash environment', () => { const root = makeTemporaryDirectory(); - const context = makeContext(root, 'local-parent'); - const key = getTwoslashCacheKey({ + const context = makeContext(root); + const base = getTwoslashCacheKey({ code: 'const value = 1;', lang: 'ts', context, }); - const candidates = Array.from( - {length: 6}, - (_, index) => `
worker-${index}
`, - ); - const moduleUrl = pathToFileURL(join(__dirname, 'twoslash-cache.ts')).href; - const script = ` - import {writeTwoslashCacheEntry} from ${JSON.stringify(moduleUrl)}; - const context = JSON.parse(process.env.TWOSLASH_TEST_CONTEXT); - writeTwoslashCacheEntry({ + expect( + getTwoslashCacheKey({ + code: 'const value = 1;', + lang: 'tsx', context, - key: process.env.TWOSLASH_TEST_KEY, - html: process.env.TWOSLASH_TEST_HTML, - }); - `; - const processes = candidates.map((html, index) => - Bun.spawn({ - cmd: [process.execPath, '-e', script], - env: { - ...process.env, - TWOSLASH_TEST_CONTEXT: JSON.stringify({ - ...context, - localRoot: join(root, `local-worker-${index}`), - }), - TWOSLASH_TEST_KEY: key, - TWOSLASH_TEST_HTML: html, - }, - stderr: 'pipe', - stdout: 'ignore', }), - ); - const exitCodes = await Promise.all( - processes.map((process) => process.exited), - ); - - expect(exitCodes).toEqual(candidates.map(() => 0)); - const cached = readTwoslashCacheEntry({ - context: makeContext(root, 'local-reader'), - key, - }); - expect(cached).not.toBeNull(); - if (cached === null) { - throw new Error('Expected a shared cache entry'); - } - - expect(candidates).toContain(cached); + ).not.toBe(base); expect( - readdirSync(context.sharedRoot!).filter( - (file) => file.endsWith('.tmp') || file.endsWith('.lock'), - ), - ).toEqual([]); - }); - - test('rejects and repairs corrupt shared entries', () => { - const root = makeTemporaryDirectory(); - const first = makeContext(root, 'local-a'); - const second = makeContext(root, 'local-b'); - const third = makeContext(root, 'local-c'); - const key = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context: first, - }); - const sharedPath = join(first.sharedRoot!, `${key}.json`); - - writeTwoslashCacheEntry({context: first, key, html: '
first
'}); - writeFileSync(sharedPath, 'corrupt', 'utf8'); - expect(readTwoslashCacheEntry({context: second, key})).toBeNull(); - - writeTwoslashCacheEntry({ - context: second, - key, - html: '
repaired
', - }); - expect(readTwoslashCacheEntry({context: third, key})).toBe( - '
repaired
', - ); - }); - - test('garbage-collects old shared entries without deleting local entries', () => { - const root = makeTemporaryDirectory(); - const context = makeContext(root, 'local-a'); - const key = getTwoslashCacheKey({ - code: 'const value = 1;', - lang: 'ts', - context, - }); - const sharedPath = join(context.sharedRoot!, `${key}.json`); - const localPath = join(context.localRoot, `${key}.json`); - - writeTwoslashCacheEntry({context, key, html: '
cached
'}); - const old = new Date(Date.now() - 10_000); - utimesSync(sharedPath, old, old); - process.env.TWOSLASH_SHARED_CACHE_GC_INTERVAL_MS = '0'; - process.env.TWOSLASH_SHARED_CACHE_MAX_AGE_MS = '1'; - - garbageCollectSharedTwoslashCache(context); - - expect(existsSync(sharedPath)).toBeFalse(); - expect(readFileSync(localPath, 'utf8')).toBe('
cached
'); + getTwoslashCacheKey({ + code: 'const value = 1;', + lang: 'ts', + context: {...context, environmentHash: 'environment-b'}, + }), + ).not.toBe(base); + for (const dependency of Object.keys( + versions, + ) as (keyof typeof versions)[]) { + expect( + getTwoslashCacheKey({ + code: 'const value = 1;', + lang: 'ts', + context: { + ...context, + versions: {...context.versions, [dependency]: '2.0.0'}, + }, + }), + ).not.toBe(base); + } }); }); diff --git a/packages/docusaurus-plugin/src/twoslash-cache.ts b/packages/docusaurus-plugin/src/twoslash-cache.ts index c749fbf3614..b61108cdd09 100644 --- a/packages/docusaurus-plugin/src/twoslash-cache.ts +++ b/packages/docusaurus-plugin/src/twoslash-cache.ts @@ -2,15 +2,11 @@ import {execFileSync} from 'child_process'; import {createHash, randomBytes} from 'crypto'; import { existsSync, - linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, - statSync, - unlinkSync, - utimesSync, writeFileSync, } from 'fs'; import {dirname, join, relative, resolve} from 'path'; @@ -20,12 +16,6 @@ export const TWOSLASH_THEME = 'github-dark'; export const TWOSLASH_EXPLICIT_TRIGGER = false; export const TWOSLASH_RENDERER = 'classic'; -const SHARED_CACHE_FILE_VERSION = 1; -const DAY_MS = 24 * 60 * 60 * 1000; -const DEFAULT_MAX_CACHE_AGE_MS = 90 * DAY_MS; -const DEFAULT_MAX_CACHE_BYTES = 512 * 1024 * 1024; -const DEFAULT_GC_INTERVAL_MS = DAY_MS; - export const getTwoslashCompilerOptions = () => ({ types: ['node'], target: 99 /* ESNext */, @@ -48,45 +38,16 @@ interface TwoslashWorkspacePackage { export interface TwoslashCacheContext { localRoot: string; - sharedRoot: string | null; environmentHash: string; versions: TwoslashVersions; workspacePackages: Record; } -interface SharedCacheHeader { - fileVersion: number; - key: string; - contentHash: string; -} - const environmentHashCache = new Map(); -const isNodeError = (error: unknown, code: string): boolean => { - return ( - error instanceof Error && - 'code' in error && - (error as NodeJS.ErrnoException).code === code - ); -}; - -const safeUnlink = (path: string): void => { - try { - unlinkSync(path); - } catch (error) { - if (!isNodeError(error, 'ENOENT')) { - throw error; - } - } -}; - -const getTemporaryPath = (path: string): string => { - return `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; -}; - const writeFileAtomically = (path: string, contents: string): void => { mkdirSync(dirname(path), {recursive: true}); - const temporaryPath = getTemporaryPath(path); + const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; writeFileSync(temporaryPath, contents, {encoding: 'utf8', flag: 'wx'}); try { @@ -97,9 +58,7 @@ const writeFileAtomically = (path: string, contents: string): void => { throw error; } } finally { - if (existsSync(temporaryPath)) { - safeUnlink(temporaryPath); - } + rmSync(temporaryPath, {force: true}); } }; @@ -152,24 +111,6 @@ const getGitPath = (cwd: string, argument: string): string | null => { } }; -export const getSharedTwoslashCacheRoot = (cwd: string): string | null => { - if (process.env.TWOSLASH_SHARED_CACHE_DIR) { - return resolve(process.env.TWOSLASH_SHARED_CACHE_DIR); - } - - const gitCommonDir = getGitPath(cwd, '--git-common-dir'); - if (!gitCommonDir) { - return null; - } - - return join( - gitCommonDir, - 'remotion-cache', - 'twoslash', - `v${TWOSLASH_CACHE_SCHEMA_VERSION}`, - ); -}; - const getRepositoryRoot = (docsRoot: string): string => { return ( getGitPath(docsRoot, '--show-toplevel') ?? resolve(docsRoot, '..', '..') @@ -236,11 +177,24 @@ export const getTwoslashEnvironmentHash = (docsRoot: string): string => { return cached; } - // External package versions, TypeScript's ambient types and package-manager - // resolutions can affect any snippet. Workspace declarations are fingerprinted - // separately so a change to one package does not invalidate every snippet. + // External package resolutions can affect any snippet. Hash the committed + // lockfile so an install-time rewrite cannot invalidate restored cache entries. const hash = createHash('sha256'); - hashFile(hash, repositoryRoot, join(repositoryRoot, 'bun.lock')); + hash.update('bun.lock\0'); + try { + hash.update( + execFileSync('git', ['-C', repositoryRoot, 'show', 'HEAD:bun.lock'], { + encoding: 'utf8', + maxBuffer: 100 * 1024 * 1024, + }), + ); + } catch { + const lockfile = join(repositoryRoot, 'bun.lock'); + hash.update( + existsSync(lockfile) ? readFileSync(lockfile, 'utf8') : '', + ); + } + const digest = hash.digest('hex'); environmentHashCache.set(repositoryRoot, digest); return digest; @@ -299,7 +253,6 @@ export const createTwoslashCacheContext = ({ }): TwoslashCacheContext => { return { localRoot: join(docsRoot, 'node_modules', '.cache', 'twoslash'), - sharedRoot: getSharedTwoslashCacheRoot(docsRoot), environmentHash: getTwoslashEnvironmentHash(docsRoot), versions, workspacePackages: getTwoslashWorkspacePackages(docsRoot), @@ -398,135 +351,6 @@ export const getTwoslashLocalCachePath = ( return join(context.localRoot, `${key}.json`); }; -const getSharedCachePath = ( - context: TwoslashCacheContext, - key: string, -): string | null => { - return context.sharedRoot ? join(context.sharedRoot, `${key}.json`) : null; -}; - -const serializeSharedCacheEntry = (key: string, html: string): string => { - const header: SharedCacheHeader = { - fileVersion: SHARED_CACHE_FILE_VERSION, - key, - contentHash: createHash('sha256').update(html).digest('hex'), - }; - return `${JSON.stringify(header)}\n${html}`; -}; - -const deserializeSharedCacheEntry = ( - contents: string, - expectedKey: string, -): string | null => { - const headerEnd = contents.indexOf('\n'); - if (headerEnd === -1) { - return null; - } - - try { - const header = JSON.parse( - contents.slice(0, headerEnd), - ) as SharedCacheHeader; - const html = contents.slice(headerEnd + 1); - if ( - header.fileVersion !== SHARED_CACHE_FILE_VERSION || - header.key !== expectedKey || - header.contentHash !== createHash('sha256').update(html).digest('hex') - ) { - return null; - } - - return html; - } catch { - return null; - } -}; - -const readSharedCacheEntry = (path: string, key: string): string | null => { - try { - return deserializeSharedCacheEntry(readFileSync(path, 'utf8'), key); - } catch { - return null; - } -}; - -const touchSharedCacheEntry = (path: string): void => { - try { - const stats = statSync(path); - if (Date.now() - stats.mtimeMs > DAY_MS) { - const now = new Date(); - utimesSync(path, now, now); - } - } catch { - // Cache maintenance must not fail a docs build. - } -}; - -const publishSharedCacheEntry = ( - path: string, - key: string, - html: string, -): void => { - const existing = readSharedCacheEntry(path, key); - if (existing !== null) { - touchSharedCacheEntry(path); - return; - } - - mkdirSync(dirname(path), {recursive: true}); - const temporaryPath = getTemporaryPath(path); - writeFileSync(temporaryPath, serializeSharedCacheEntry(key, html), { - encoding: 'utf8', - flag: 'wx', - }); - - try { - if (!existsSync(path)) { - try { - // A hard link installs the immutable object without replacing a winner. - linkSync(temporaryPath, path); - return; - } catch (error) { - if ( - !isNodeError(error, 'EEXIST') && - readSharedCacheEntry(path, key) !== null - ) { - return; - } - } - } - - if (readSharedCacheEntry(path, key) !== null) { - return; - } - - // Corrupt entries and filesystems without hard links use a per-key lock - // before atomically installing the replacement. - const lockPath = `${path}.lock`; - try { - mkdirSync(lockPath); - } catch { - return; - } - - try { - if (readSharedCacheEntry(path, key) === null) { - if (existsSync(path)) { - safeUnlink(path); - } - - renameSync(temporaryPath, path); - } - } finally { - rmSync(lockPath, {recursive: true, force: true}); - } - } finally { - if (existsSync(temporaryPath)) { - safeUnlink(temporaryPath); - } - } -}; - export const readTwoslashCacheEntry = ({ context, key, @@ -534,29 +358,15 @@ export const readTwoslashCacheEntry = ({ context: TwoslashCacheContext; key: string; }): string | null => { - const localPath = getTwoslashLocalCachePath(context, key); try { - const localContents = readFileSync(localPath, 'utf8'); - if (localContents.length > 0) { - return localContents; - } + const contents = readFileSync( + getTwoslashLocalCachePath(context, key), + 'utf8', + ); + return contents.length > 0 ? contents : null; } catch { - // Fall through to the shared cache. - } - - const sharedPath = getSharedCachePath(context, key); - if (!sharedPath) { return null; } - - const html = readSharedCacheEntry(sharedPath, key); - if (html === null) { - return null; - } - - touchSharedCacheEntry(sharedPath); - writeFileAtomically(localPath, html); - return html; }; export const writeTwoslashCacheEntry = ({ @@ -569,156 +379,4 @@ export const writeTwoslashCacheEntry = ({ html: string; }): void => { writeFileAtomically(getTwoslashLocalCachePath(context, key), html); - const sharedPath = getSharedCachePath(context, key); - if (!sharedPath) { - return; - } - - try { - publishSharedCacheEntry(sharedPath, key, html); - } catch { - // The local cache is sufficient for this build. A later prewarm can retry - // publishing to the shared cache. - } -}; - -export const publishLocalTwoslashCacheEntry = ({ - context, - key, -}: { - context: TwoslashCacheContext; - key: string; -}): void => { - const sharedPath = getSharedCachePath(context, key); - if (!sharedPath) { - return; - } - - try { - const html = readFileSync(getTwoslashLocalCachePath(context, key), 'utf8'); - if (html.length > 0) { - publishSharedCacheEntry(sharedPath, key, html); - } - } catch { - // Publishing is only an optimization. - } -}; - -const readEnvNumber = (name: string, fallback: number): number => { - const value = process.env[name]; - if (!value) { - return fallback; - } - - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; -}; - -export const garbageCollectSharedTwoslashCache = ( - context: TwoslashCacheContext, -): void => { - if (!context.sharedRoot || !existsSync(context.sharedRoot)) { - return; - } - - const now = Date.now(); - const gcIntervalMs = readEnvNumber( - 'TWOSLASH_SHARED_CACHE_GC_INTERVAL_MS', - DEFAULT_GC_INTERVAL_MS, - ); - const stampPath = join(context.sharedRoot, '.gc-stamp'); - try { - if (now - statSync(stampPath).mtimeMs < gcIntervalMs) { - return; - } - } catch { - // No previous GC stamp. - } - - const lockPath = join(context.sharedRoot, '.gc-lock'); - try { - mkdirSync(lockPath); - } catch { - try { - if (now - statSync(lockPath).mtimeMs <= DAY_MS) { - return; - } - - rmSync(lockPath, {recursive: true, force: true}); - mkdirSync(lockPath); - } catch { - return; - } - } - - try { - const maxAgeMs = readEnvNumber( - 'TWOSLASH_SHARED_CACHE_MAX_AGE_MS', - DEFAULT_MAX_CACHE_AGE_MS, - ); - const maxBytes = readEnvNumber( - 'TWOSLASH_SHARED_CACHE_MAX_BYTES', - DEFAULT_MAX_CACHE_BYTES, - ); - const entries: {path: string; size: number; mtimeMs: number}[] = []; - - for (const file of readdirSync(context.sharedRoot)) { - const path = join(context.sharedRoot, file); - if (file.endsWith('.tmp') || file.endsWith('.lock')) { - try { - if (now - statSync(path).mtimeMs > DAY_MS) { - if (file.endsWith('.lock')) { - rmSync(path, {recursive: true, force: true}); - } else { - safeUnlink(path); - } - } - } catch { - // The file disappeared concurrently. - } - - continue; - } - - if (!file.endsWith('.json')) { - continue; - } - - try { - const stats = statSync(path); - const key = file.slice(0, -'.json'.length); - if ( - now - stats.mtimeMs > maxAgeMs || - readSharedCacheEntry(path, key) === null - ) { - safeUnlink(path); - continue; - } - - entries.push({path, size: stats.size, mtimeMs: stats.mtimeMs}); - } catch { - // The entry disappeared concurrently. - } - } - - let totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0); - for (const entry of entries.sort((a, b) => a.mtimeMs - b.mtimeMs)) { - if (totalBytes <= maxBytes) { - break; - } - - try { - safeUnlink(entry.path); - totalBytes -= entry.size; - } catch { - // Keep going if another process is using or removed the entry. - } - } - - writeFileAtomically(stampPath, String(now)); - } catch { - // Shared cache maintenance must never fail prewarming. - } finally { - rmSync(lockPath, {recursive: true, force: true}); - } }; diff --git a/packages/example/e2e/error-overlay.test.mts b/packages/example/e2e/error-overlay.test.mts index c42c1642269..2a1987d7665 100644 --- a/packages/example/e2e/error-overlay.test.mts +++ b/packages/example/e2e/error-overlay.test.mts @@ -44,13 +44,17 @@ test.describe('error overlay dismissal', () => { const errorMessage = page.getByText('"radius" must be a finite number'); const openInEditorRequests: unknown[] = []; + const openInCodingAgentRequests: unknown[] = []; await page.route('**/api/default-editor-info', async (route) => { await route.fulfill({ json: { success: true, data: { - defaultEditor: null, - installedEditors: [{id: 'zed', name: 'Zed', nameWithType: 'Zed'}], + defaultEditor: 'zed', + installedEditors: [ + {id: 'zed', name: 'Zed', nameWithType: 'Zed'}, + {id: 'vscode', name: 'Code', nameWithType: 'VS Code'}, + ], }, }, }); @@ -60,13 +64,27 @@ test.describe('error overlay dismissal', () => { json: { success: true, data: { - defaultCodingAgent: null, - installedCodingAgents: [], + defaultCodingAgent: 'codex', + installedCodingAgents: [ + {id: 'codex', name: 'Codex', nameWithType: 'Codex'}, + { + id: 'claude-code', + name: 'Claude', + nameWithType: 'Claude Code', + }, + ], + installedGitClients: [], installedTerminals: [], }, }, }); }); + await page.route('**/api/open-in-coding-agent', async (route) => { + openInCodingAgentRequests.push(route.request().postDataJSON()); + await route.fulfill({ + json: {success: true, data: {success: true}}, + }); + }); await page.route('**/api/open-in-editor', async (route) => { openInEditorRequests.push(route.request().postDataJSON()); await route.fulfill({ @@ -99,6 +117,9 @@ test.describe('error overlay dismissal', () => { await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: STUDIO_URL, }); + await page.addInitScript(() => { + Object.defineProperty(window.navigator, 'platform', {value: 'Win32'}); + }); await page.goto(`${STUDIO_URL}/error-overlay-unsymbolicated-e2e`); await expect(page.getByText('Expected defaults').first()).toBeVisible({ timeout: 15_000, @@ -106,15 +127,106 @@ test.describe('error overlay dismissal', () => { await expect( page.getByText('Could not symbolicate the stack trace: Failed to fetch'), ).toBeVisible(); + await expect(page.getByRole('button', {name: 'Copy stack'})).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Search Issues Ctrl+G', + }), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Ask on Discord Ctrl+D', + }), + ).toBeVisible(); await expect( - page.getByRole('button', {name: 'Copy Stacktrace'}), + page.getByRole('button', {name: 'Fix with Codex', exact: true}), ).toBeVisible(); await expect( - page.getByRole('button', {name: /Search GitHub Issues/}), + page.getByRole('button', { + name: 'Fix with another coding agent', + }), + ).toBeVisible(); + for (const buttonName of [ + 'Copy stack', + 'Search Issues Ctrl+G', + 'Ask on Discord Ctrl+D', + 'Fix with Codex', + ]) { + const button = page.getByRole('button', {name: buttonName}); + await expect(button).toHaveCSS('border-style', 'none'); + await expect(button).toHaveCSS('cursor', 'default'); + } + await expect( + page.getByRole('button', {name: 'Retry', exact: true}), + ).toHaveCount(0); + const errorMessageBounds = await page + .getByText('Expected defaults', {exact: true}) + .first() + .boundingBox(); + const fixWithAgentButton = page.getByRole('button', { + name: 'Fix with Codex', + exact: true, + }); + const fixWithAgentButtonBounds = await fixWithAgentButton.boundingBox(); + const copyButton = page.getByRole('button', {name: 'Copy stack'}); + const copyButtonBounds = await copyButton.boundingBox(); + const fixWithAgentIconLeft = await fixWithAgentButton + .locator('img') + .evaluate((element) => element.getBoundingClientRect().left); + const actionTypography = await Promise.all( + [fixWithAgentButton, copyButton].map((button) => + button.evaluate((element) => { + const style = window.getComputedStyle(element); + return {fontFamily: style.fontFamily, fontSize: style.fontSize}; + }), + ), + ); + if (!errorMessageBounds || !fixWithAgentButtonBounds || !copyButtonBounds) { + throw new Error( + 'Expected the error message and action row to be visible', + ); + } + + expect(actionTypography[0]).toEqual(actionTypography[1]); + expect( + Math.abs(fixWithAgentButtonBounds.y - copyButtonBounds.y), + ).toBeLessThan(1); + expect( + Math.abs(fixWithAgentButtonBounds.height - copyButtonBounds.height), + ).toBeLessThan(1); + expect(Math.abs(fixWithAgentIconLeft - errorMessageBounds.x)).toBeLessThan( + 1, + ); + expect( + fixWithAgentButtonBounds.y - + (errorMessageBounds.y + errorMessageBounds.height), + ).toBeLessThan(16); + await page + .getByRole('button', {name: 'Fix with Codex', exact: true}) + .click(); + await expect + .poll(() => openInCodingAgentRequests) + .toEqual([ + expect.objectContaining({ + codingAgentId: 'codex', + prompt: expect.stringMatching( + /TypeError: Expected defaults[\s\S]*webpack-internal:\/\/\/cannot-symbolicate\.js/, + ), + }), + ]); + + const macPage = await context.newPage(); + await macPage.addInitScript(() => { + Object.defineProperty(window.navigator, 'platform', {value: 'MacIntel'}); + }); + await macPage.goto(`${STUDIO_URL}/error-overlay-unsymbolicated-e2e`); + await expect( + macPage.getByRole('button', {name: 'Search Issues ⌘G'}), ).toBeVisible(); await expect( - page.getByRole('button', {name: /Ask on Discord/}), + macPage.getByRole('button', {name: 'Ask on Discord ⌘D'}), ).toBeVisible(); + await macPage.close(); const rawStack = page.getByLabel('Unsymbolicated stack trace'); await expect(rawStack).toContainText( @@ -135,7 +247,7 @@ test.describe('error overlay dismissal', () => { ), ).toBe(true); - await page.getByRole('button', {name: 'Copy Stacktrace'}).click(); + await page.getByRole('button', {name: 'Copy stack'}).click(); await expect(page.getByRole('button', {name: 'Copied!'})).toBeVisible(); expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( 'webpack-internal:///cannot-symbolicate.js', @@ -150,10 +262,72 @@ test.describe('error overlay dismissal', () => { // 1. Introduce the bug: remove the `radius: 24` argument. await writeAndWaitForRebuild(buggyContent, 'introducing the bug'); await expect(errorMessage).toBeVisible({timeout: 15_000}); - await page.getByRole('button', {name: 'Open in Zed', exact: true}).click(); + await expect( + page.getByText('ErrorOverlayRepro', {exact: true}), + ).toBeVisible(); + await expect( + page.getByText('react_stack_bottom_frame', {exact: true}), + ).toHaveCount(0); + await page.getByRole('button', {name: 'Copy stack'}).click(); + const copiedSymbolicatedStack = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(copiedSymbolicatedStack).toContain('ErrorOverlayRepro'); + expect(copiedSymbolicatedStack).not.toContain('react_stack_bottom_frame'); + const openInEditorButtonBounds = await page + .locator('#error-overlay-open-in-editor') + .boundingBox(); + const symbolicatedFixButtonBounds = await page + .getByRole('button', {name: 'Fix with Codex', exact: true}) + .boundingBox(); + const symbolicatedCopyButtonBounds = await page + .getByRole('button', {name: 'Copy stack'}) + .boundingBox(); + if ( + !openInEditorButtonBounds || + !symbolicatedFixButtonBounds || + !symbolicatedCopyButtonBounds + ) { + throw new Error('Expected the symbolicated error actions to be visible'); + } + + expect( + Math.abs(openInEditorButtonBounds.y - symbolicatedFixButtonBounds.y), + ).toBeLessThan(1); + expect( + Math.abs(openInEditorButtonBounds.y - symbolicatedCopyButtonBounds.y), + ).toBeLessThan(1); + await page.locator('#error-overlay-open-in-editor').click(); await expect .poll(() => openInEditorRequests) .toEqual([expect.objectContaining({editorId: 'zed'})]); + const openInAnotherApp = page.locator('#error-overlay-open-in-another-app'); + await openInAnotherApp.click(); + await expect( + page.getByRole('button', {name: 'VS Code', exact: true}), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Configure default apps...', + exact: true, + }), + ).toBeVisible(); + await page.getByRole('button', {name: 'VS Code', exact: true}).click(); + await expect + .poll(() => openInEditorRequests) + .toEqual([ + expect.objectContaining({editorId: 'zed'}), + expect.objectContaining({editorId: 'vscode'}), + ]); + await openInAnotherApp.click(); + await page + .getByRole('button', { + name: 'Configure default apps...', + exact: true, + }) + .click(); + await expect(page.getByText('Default editor', {exact: true})).toBeVisible(); + await page.keyboard.press('Escape'); // 2. Fix the bug: restore the `radius: 24` argument. The error UI should // dismiss once HMR applies the fix. diff --git a/packages/example/e2e/studio.test.mts b/packages/example/e2e/studio.test.mts index e9ec2f4fa2b..6dfb1e38df4 100644 --- a/packages/example/e2e/studio.test.mts +++ b/packages/example/e2e/studio.test.mts @@ -3292,7 +3292,7 @@ test.describe('visual mode', () => { await timelineGridline.click({button: 'right'}); await page.getByRole('button', {name: 'Open in...', exact: true}).click(); await page - .getByRole('button', {name: 'Change default apps...', exact: true}) + .getByRole('button', {name: 'Configure default apps...', exact: true}) .click(); const settings = page.getByRole('dialog'); @@ -3514,16 +3514,16 @@ test.describe('visual mode', () => { const openInAnotherApp = page .getByTitle(exampleDir) .getByRole('button', {name: 'Open in another app'}); - const changeDefaultApps = page.getByRole('button', { - name: 'Change default apps...', + const configureDefaultApps = page.getByRole('button', { + name: 'Configure default apps...', }); await openInAnotherApp.click(); - await expect(changeDefaultApps).toBeVisible(); + await expect(configureDefaultApps).toBeVisible(); // The menu overlay intercepts pointerleave; clicking it closes the menu // through the same outside-click path a user would take. await page.mouse.click(10, 100); - await expect(changeDefaultApps).toBeHidden(); + await expect(configureDefaultApps).toBeHidden(); await expect(openInAnotherApp).toHaveCSS( 'background-color', 'rgba(0, 0, 0, 0)', diff --git a/packages/studio-codemods/src/delete-jsx-node.ts b/packages/studio-codemods/src/delete-jsx-node.ts index fd697077634..9e8d7fbbfaf 100644 --- a/packages/studio-codemods/src/delete-jsx-node.ts +++ b/packages/studio-codemods/src/delete-jsx-node.ts @@ -24,6 +24,7 @@ import type { TSAsExpression, VariableDeclarator, } from '@babel/types'; +import type {SequenceNodePathRemapping} from '@remotion/studio-shared'; import * as recast from 'recast'; import type {SequenceNodePath} from 'remotion'; import { @@ -541,10 +542,7 @@ export const deleteJsxNodes = ({ formatted: boolean; nodeLabels: string[]; logLines: number[]; - nodePathRemappings: Array<{ - oldNodePath: SequenceNodePath; - newNodePath: SequenceNodePath | null; - }>; + nodePathRemappings: SequenceNodePathRemapping[]; }> => { if (nodePaths.length === 0) { throw new Error('No JSX nodes were specified for deletion'); diff --git a/packages/studio-codemods/src/get-node-path-remappings.ts b/packages/studio-codemods/src/get-node-path-remappings.ts index d9010fee473..11e585de19b 100644 --- a/packages/studio-codemods/src/get-node-path-remappings.ts +++ b/packages/studio-codemods/src/get-node-path-remappings.ts @@ -8,6 +8,7 @@ import {parseAst} from './sequence-props/parse-ast'; export type CapturedJsxNodePath = { node: JSXOpeningElement; nodePath: SequenceNodePath; + signature: string; }; export const captureJsxNodePaths = (ast: File): CapturedJsxNodePath[] => { @@ -17,6 +18,7 @@ export const captureJsxNodePaths = (ast: File): CapturedJsxNodePath[] => { captured.push({ node: path.node as JSXOpeningElement, nodePath: getNodePathForRecastPath(path, ast), + signature: recast.prettyPrint(path.node as JSXOpeningElement).code, }); return this.traverse(path); }, @@ -63,17 +65,34 @@ export const getNodePathRemappings = ({ finalNodePathByNode.set(nodesAfterMutation[i], finalNodePaths[i]); } - const nodePathRemappings = captured.flatMap(({node, nodePath}) => { - const newNodePath = finalNodePathByNode.get(node) ?? null; - if ( - newNodePath !== null && - JSON.stringify(nodePath) === JSON.stringify(newNodePath) - ) { - return []; + const capturedNodes = new Set(captured.map(({node}) => node)); + const nodePathRemappings: SequenceNodePathRemapping[] = captured.flatMap( + ({node, nodePath, signature}) => { + const newNodePath = finalNodePathByNode.get(node) ?? null; + if ( + newNodePath !== null && + JSON.stringify(nodePath) === JSON.stringify(newNodePath) && + recast.prettyPrint(node).code === signature + ) { + return []; + } + + return [{oldNodePath: nodePath, newNodePath}]; + }, + ); + + for (const node of nodesAfterMutation) { + if (capturedNodes.has(node)) { + continue; } - return [{oldNodePath: nodePath, newNodePath}]; - }); + const newNodePath = finalNodePathByNode.get(node); + if (!newNodePath) { + throw new Error('Could not map inserted JSX node path'); + } + + nodePathRemappings.push({oldNodePath: null, newNodePath}); + } return {finalNodePathByNode, nodePathRemappings}; }; diff --git a/packages/studio-codemods/src/index.ts b/packages/studio-codemods/src/index.ts index cb9cc28f7b2..4fdc2ef5ac2 100644 --- a/packages/studio-codemods/src/index.ts +++ b/packages/studio-codemods/src/index.ts @@ -974,7 +974,8 @@ const getNodePathRemappings = ({ }); let nextAfterIndex = 0; - return before.flatMap( + const matchedAfterIndexes = new Set(); + const remappings = before.flatMap( ({nodePath, signature}): SequenceNodePathRemapping[] => { const matchedIndex = after.findIndex( (item, index) => @@ -987,6 +988,7 @@ const getNodePathRemappings = ({ } nextAfterIndex = matchedIndex + 1; + matchedAfterIndexes.add(matchedIndex); const newNodePath = after[matchedIndex].nodePath; if (JSON.stringify(nodePath) === JSON.stringify(newNodePath)) { return []; @@ -995,6 +997,14 @@ const getNodePathRemappings = ({ return [{oldNodePath: nodePath, newNodePath}]; }, ); + + for (let i = 0; i < after.length; i++) { + if (!matchedAfterIndexes.has(i)) { + remappings.push({oldNodePath: null, newNodePath: after[i].nodePath}); + } + } + + return remappings; }; export const insertSolidIntoProjectWithNodePathRemappings = < diff --git a/packages/studio-codemods/src/insert-jsx-element.ts b/packages/studio-codemods/src/insert-jsx-element.ts index 65d7d9a1cea..e15f465c472 100644 --- a/packages/studio-codemods/src/insert-jsx-element.ts +++ b/packages/studio-codemods/src/insert-jsx-element.ts @@ -12,6 +12,7 @@ import type { JSXElement, JSXOpeningElement, JSXSpreadAttribute, + NullLiteral, ObjectProperty, VariableDeclaration, } from '@babel/types'; @@ -30,16 +31,13 @@ import { captureJsxNodePaths, getNodePathRemappings, } from './get-node-path-remappings'; +import {recastLocToOffset} from './recast-loc-to-offset'; import { ensureNamedImport, getImportedName, insertImportDeclaration, } from './sequence-props/imports'; -import { - parseAst, - parseAstForReadOnly, - serializeAst, -} from './sequence-props/parse-ast'; +import {parseAst, parseAstForReadOnly} from './sequence-props/parse-ast'; import {stripParenthesizedExtra} from './strip-parenthesized-extra'; import {parseValueExpression} from './update-nested-prop'; @@ -177,6 +175,17 @@ type SourceLocation = { column: number; }; +type SourceEdit = { + end: number; + replacement: string; + start: number; +}; + +type ImportSnapshot = { + declaration: ImportDeclaration; + specifiers: NonNullable; +}; + type NodeWithLocation = { loc?: { start: { @@ -2037,6 +2046,830 @@ const addElementToComponentRoot = ({ return rootNode.loc?.start.line ?? 1; }; +const getNullRootFromFunctionLike = ( + fn: FunctionLikeNode, +): NullLiteral | null => { + if (fn.type === 'ArrowFunctionExpression' && fn.body.type === 'NullLiteral') { + return fn.body as NullLiteral; + } + + if (fn.body.type !== 'BlockStatement') { + return null; + } + + const returnStatement = getTopLevelReturnStatement(fn.body.body); + return returnStatement?.argument?.type === 'NullLiteral' + ? (returnStatement.argument as NullLiteral) + : null; +}; + +const getNullComponentRoot = ( + declaration: LocalComponentDeclaration | DefaultExportDeclaration, +): NullLiteral | null => { + if (declaration.type === 'VariableDeclarator') { + if ( + !declaration.init || + (declaration.init.type !== 'ArrowFunctionExpression' && + declaration.init.type !== 'FunctionExpression') + ) { + return null; + } + + return getNullRootFromFunctionLike(declaration.init); + } + + if ( + declaration.type === 'ArrowFunctionExpression' || + declaration.type === 'FunctionExpression' || + declaration.type === 'FunctionDeclaration' + ) { + return getNullRootFromFunctionLike(declaration); + } + + if (declaration.type !== 'ClassDeclaration') { + return null; + } + + const renderMethod = findRenderMethod(declaration); + if (!renderMethod) { + return null; + } + + const returnStatement = getTopLevelReturnStatement(renderMethod.body.body); + return returnStatement?.argument?.type === 'NullLiteral' + ? (returnStatement.argument as NullLiteral) + : null; +}; + +const getLineIndent = (input: string, offset: number) => { + const lineStart = input.lastIndexOf('\n', offset - 1) + 1; + return input.slice(lineStart, offset).match(/^\s*/)?.[0] ?? ''; +}; + +const getIndentationUnit = ( + input: string, + prettierConfigOverride: Record | null, +) => { + if (/^\t+/m.test(input)) { + return '\t'; + } + + const indentation = input.match(/^([ ]+)\S/m)?.[1].length; + if (indentation) { + return ' '.repeat(indentation > 1 ? indentation : 2); + } + + if (prettierConfigOverride?.useTabs === true) { + return '\t'; + } + + const tabWidth = prettierConfigOverride?.tabWidth; + return ' '.repeat( + typeof tabWidth === 'number' && Number.isInteger(tabWidth) && tabWidth > 0 + ? tabWidth + : 2, + ); +}; + +const renderImportSpecifier = ( + specifier: NonNullable[number], +) => { + if (specifier.type === 'ImportDefaultSpecifier') { + return specifier.local.name; + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + return `* as ${specifier.local.name}`; + } + + const importedName = getImportedName(specifier); + const localName = specifier.local?.name ?? importedName; + const rendered = + importedName === localName + ? importedName + : `${importedName} as ${localName}`; + return specifier.importKind === 'type' ? `type ${rendered}` : rendered; +}; + +const renderImportDeclaration = ({ + bracketSpacing, + declaration, + quote, + semicolon, +}: { + bracketSpacing: boolean; + declaration: ImportDeclaration; + quote: '"' | "'"; + semicolon: string; +}) => { + const specifiers = declaration.specifiers ?? []; + const defaultSpecifier = specifiers.find( + (specifier) => specifier.type === 'ImportDefaultSpecifier', + ); + const namespaceSpecifier = specifiers.find( + (specifier) => specifier.type === 'ImportNamespaceSpecifier', + ); + const namedSpecifiers = specifiers.filter( + (specifier) => specifier.type === 'ImportSpecifier', + ); + const parts = [ + ...(defaultSpecifier ? [renderImportSpecifier(defaultSpecifier)] : []), + ...(namespaceSpecifier ? [renderImportSpecifier(namespaceSpecifier)] : []), + ...(namedSpecifiers.length + ? [ + `{${bracketSpacing ? ' ' : ''}${namedSpecifiers + .map(renderImportSpecifier) + .join(', ')}${bracketSpacing ? ' ' : ''}}`, + ] + : []), + ]; + const source = + quote === '"' + ? JSON.stringify(declaration.source.value) + : `'${declaration.source.value.replaceAll("'", "\\'")}'`; + return `import ${parts.join(', ')} from ${source}${semicolon}`; +}; + +const getImportBracketSpacing = ({ + declaration, + input, + prettierConfigOverride, +}: { + declaration: ImportDeclaration | null; + input: string; + prettierConfigOverride: Record | null; +}) => { + if (declaration?.loc) { + const source = input.slice( + recastLocToOffset(input, declaration.loc.start), + recastLocToOffset(input, declaration.loc.end), + ); + const openingBrace = source.indexOf('{'); + const closingBrace = source.lastIndexOf('}'); + if (openingBrace !== -1 && closingBrace > openingBrace) { + return ( + /\s/.test(source[openingBrace + 1]) && + /\s/.test(source[closingBrace - 1]) + ); + } + } + + return prettierConfigOverride?.bracketSpacing !== false; +}; + +const getInsertImportSourceEdits = ({ + ast, + input, + prettierConfigOverride, + snapshots, +}: { + ast: File; + input: string; + prettierConfigOverride: Record | null; + snapshots: ImportSnapshot[]; +}): SourceEdit[] => { + const edits: SourceEdit[] = []; + const snapshotByDeclaration = new Map( + snapshots.map((snapshot) => [snapshot.declaration, snapshot]), + ); + const importWithNamedSpecifiers = + snapshots.find((snapshot) => + snapshot.specifiers.some( + (specifier) => specifier.type === 'ImportSpecifier', + ), + )?.declaration ?? null; + const fallbackBracketSpacing = getImportBracketSpacing({ + declaration: importWithNamedSpecifiers, + input, + prettierConfigOverride, + }); + const newDeclarations: ImportDeclaration[] = []; + + for (const statement of ast.program.body) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + + const snapshot = snapshotByDeclaration.get(statement); + if (!snapshot) { + newDeclarations.push(statement); + continue; + } + + const addedSpecifiers = (statement.specifiers ?? []).filter( + (specifier) => !snapshot.specifiers.includes(specifier), + ); + if (addedSpecifiers.length === 0) { + continue; + } + + if ( + addedSpecifiers.every( + (specifier) => specifier.type === 'ImportDefaultSpecifier', + ) && + statement.loc + ) { + const importStart = recastLocToOffset(input, statement.loc.start); + const importPrefix = input.slice(importStart).match(/^import\s+/)?.[0]; + if (!importPrefix) { + throw new Error('Could not locate the import prefix to update'); + } + + const offset = importStart + importPrefix.length; + edits.push({ + end: offset, + replacement: `${addedSpecifiers.map(renderImportSpecifier).join(', ')}, `, + start: offset, + }); + continue; + } + + if ( + addedSpecifiers.some((specifier) => specifier.type !== 'ImportSpecifier') + ) { + if (!statement.loc) { + throw new Error('Could not locate the import to update'); + } + + const fullImportStart = recastLocToOffset(input, statement.loc.start); + const fullImportEnd = recastLocToOffset(input, statement.loc.end); + const fullImport = input.slice(fullImportStart, fullImportEnd); + edits.push({ + end: fullImportEnd, + replacement: renderImportDeclaration({ + bracketSpacing: getImportBracketSpacing({ + declaration: statement, + input, + prettierConfigOverride, + }), + declaration: statement, + quote: fullImport.includes('"') ? '"' : "'", + semicolon: fullImport.trimEnd().endsWith(';') ? ';' : '', + }), + start: fullImportStart, + }); + continue; + } + + const rendered = addedSpecifiers.map(renderImportSpecifier).join(', '); + const lastNamedSpecifier = snapshot.specifiers.findLast( + (specifier) => specifier.type === 'ImportSpecifier', + ); + if (lastNamedSpecifier?.loc) { + const offset = recastLocToOffset(input, lastNamedSpecifier.loc.end); + edits.push({ + end: offset, + replacement: `, ${rendered}`, + start: offset, + }); + continue; + } + + const defaultSpecifier = snapshot.specifiers.find( + (specifier) => specifier.type === 'ImportDefaultSpecifier', + ); + if (defaultSpecifier?.loc) { + const offset = recastLocToOffset(input, defaultSpecifier.loc.end); + edits.push({ + end: offset, + replacement: `, {${fallbackBracketSpacing ? ' ' : ''}${rendered}${fallbackBracketSpacing ? ' ' : ''}}`, + start: offset, + }); + continue; + } + + if (!statement.loc) { + throw new Error('Could not locate the import to update'); + } + + const start = recastLocToOffset(input, statement.loc.start); + const end = recastLocToOffset(input, statement.loc.end); + const original = input.slice(start, end); + edits.push({ + end, + replacement: renderImportDeclaration({ + bracketSpacing: fallbackBracketSpacing, + declaration: statement, + quote: original.includes('"') ? '"' : "'", + semicolon: original.trimEnd().endsWith(';') ? ';' : '', + }), + start, + }); + } + + if (newDeclarations.length > 0) { + const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; + const firstImport = snapshots[0]?.declaration; + const firstImportSource = firstImport?.source.loc + ? input.slice( + recastLocToOffset(input, firstImport.source.loc.start), + recastLocToOffset(input, firstImport.source.loc.end), + ) + : null; + const quote = firstImportSource?.startsWith('"') + ? ('"' as const) + : firstImportSource?.startsWith("'") || + prettierConfigOverride?.singleQuote === true + ? ("'" as const) + : ('"' as const); + const semicolon = firstImport?.loc + ? input + .slice( + recastLocToOffset(input, firstImport.loc.start), + recastLocToOffset(input, firstImport.loc.end), + ) + .trimEnd() + .endsWith(';') + ? ';' + : '' + : ';'; + const rendered = newDeclarations + .map((declaration) => + renderImportDeclaration({ + bracketSpacing: fallbackBracketSpacing, + declaration, + quote, + semicolon, + }), + ) + .join(endOfLine); + if (firstImport?.loc) { + const offset = recastLocToOffset(input, firstImport.loc.start); + edits.push({ + end: offset, + replacement: `${rendered}${endOfLine}`, + start: offset, + }); + } else { + edits.push({end: 0, replacement: `${rendered}${endOfLine}`, start: 0}); + } + } + + return edits; +}; + +const indentExistingJsx = ({ + indent, + original, + originalIndent, +}: { + indent: string; + original: string; + originalIndent: string; +}) => { + return original + .split(/\r?\n/) + .map((line, index) => { + if (index === 0) { + return `${indent}${line}`; + } + + return `${indent}${line.startsWith(originalIndent) ? line.slice(originalIndent.length) : line.trimStart()}`; + }) + .join(original.includes('\r\n') ? '\r\n' : '\n'); +}; + +const getJsxIdentifierName = (element: namedTypes.JSXElement) => { + const {name} = element.openingElement; + if (name.type !== 'JSXIdentifier') { + throw new Error('Expected the inserted Solid to have an identifier name'); + } + + return name.name; +}; + +const getPositionStyleSource = ( + position: InsertableCompositionElementPosition | null, + prettierConfigOverride: Record | null, +) => { + const quote = prettierConfigOverride?.singleQuote === true ? "'" : '"'; + const spacing = prettierConfigOverride?.bracketSpacing === false ? '' : ' '; + const translate = position + ? `, translate: ${quote}${formatTranslateValue(position)}${quote}` + : ''; + return `style={{${spacing}position: ${quote}absolute${quote}${translate}${spacing}}}`; +}; + +const getSolidInsertionSource = ({ + element, + finalElement, + height, + input, + position, + prettierConfigOverride, + sequenceWrapper, + width, +}: { + element: namedTypes.JSXElement; + finalElement: namedTypes.JSXElement; + height: number; + input: string; + position: InsertableCompositionElementPosition | null; + prettierConfigOverride: Record | null; + sequenceWrapper: { + dimensions: {width: number; height: number} | null; + durationInFrames: number | null; + from: number | null; + name: string | null; + position: InsertableCompositionElementPosition | null; + } | null; + width: number; +}) => { + const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; + const unit = getIndentationUnit(input, prettierConfigOverride); + const solid = [ + `<${getJsxIdentifierName(element)}`, + `${unit}width={${width}}`, + `${unit}height={${height}}`, + `${unit}color="gray"`, + `${unit}${getPositionStyleSource(position, prettierConfigOverride)}`, + '/>', + ].join(endOfLine); + if (finalElement === element) { + return solid; + } + + if (sequenceWrapper === null) { + throw new Error('Expected insertion Sequence metadata'); + } + + const attributes = [ + ...(sequenceWrapper.from === null + ? [] + : [`from={${sequenceWrapper.from}}`]), + ...(sequenceWrapper.name === null + ? [] + : [`name=${JSON.stringify(sequenceWrapper.name)}`]), + ...(sequenceWrapper.dimensions === null + ? [] + : [ + `width={${sequenceWrapper.dimensions.width}}`, + `height={${sequenceWrapper.dimensions.height}}`, + ]), + ...(sequenceWrapper.durationInFrames === null + ? [] + : [`durationInFrames={${sequenceWrapper.durationInFrames}}`]), + getPositionStyleSource(sequenceWrapper.position, prettierConfigOverride), + ]; + const sequenceName = getJsxIdentifierName(finalElement); + return [ + `<${sequenceName} ${attributes.join(' ')}>`, + ...solid.split(/\r?\n/).map((line) => `${unit}${line}`), + ``, + ].join(endOfLine); +}; + +const indentInsertedJsx = ({ + indent, + insertion, +}: { + indent: string; + insertion: string; +}) => { + return insertion + .split(/\r?\n/) + .map((line) => `${indent}${line}`) + .join(insertion.includes('\r\n') ? '\r\n' : '\n'); +}; + +const printInsertedJsx = ({ + element, + input, + prettierConfigOverride, +}: { + element: namedTypes.JSXElement | namedTypes.JSXFragment; + input: string; + prettierConfigOverride: Record | null; +}): string => { + const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; + const unit = getIndentationUnit(input, prettierConfigOverride); + const printWidth = prettierConfigOverride?.printWidth; + const configuredTabWidth = prettierConfigOverride?.tabWidth; + const tabWidth = + typeof configuredTabWidth === 'number' && + Number.isInteger(configuredTabWidth) && + configuredTabWidth > 0 + ? configuredTabWidth + : 2; + recast.types.visit(element, { + visitObjectProperty(path) { + const {node} = path; + if ( + !node.computed && + node.key.type === 'StringLiteral' && + identifierRegex.test(node.key.value) + ) { + node.key = recast.types.builders.identifier(node.key.value); + } + + this.traverse(path); + return undefined; + }, + }); + const printNode = (node: namedTypes.Node) => { + return recast.prettyPrint(node, { + objectCurlySpacing: prettierConfigOverride?.bracketSpacing !== false, + quote: prettierConfigOverride?.singleQuote === true ? 'single' : 'double', + tabWidth, + useTabs: false, + wrapColumn: typeof printWidth === 'number' ? printWidth : 80, + }).code; + }; + + const normalizeIndentation = (code: string) => { + return code + .split(/\r?\n/) + .map((line) => { + const spaces = line.match(/^ */)?.[0].length ?? 0; + const indentationLevels = Math.floor(spaces / tabWidth); + const remainingSpaces = spaces % tabWidth; + return `${unit.repeat(indentationLevels)}${' '.repeat(remainingSpaces)}${line.slice(spaces)}`; + }) + .join(endOfLine); + }; + + const printOpeningElement = (opening: namedTypes.JSXOpeningElement) => { + const name = printNode(opening.name); + const attributes = (opening.attributes ?? []).map((attribute) => { + if ( + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.value?.type === 'StringLiteral' + ) { + return `${attribute.name.name}=${JSON.stringify(attribute.value.value)}`; + } + + return normalizeIndentation(printNode(attribute)); + }); + const suffix = opening.selfClosing ? ' />' : '>'; + const singleLine = `<${name}${attributes.length === 0 ? '' : ` ${attributes.join(' ')}`}${suffix}`; + if ( + !attributes.some((attribute) => attribute.includes(endOfLine)) && + singleLine.length <= (typeof printWidth === 'number' ? printWidth : 80) + ) { + return singleLine; + } + + return [ + `<${name}`, + ...attributes.map((attribute) => + indentInsertedJsx({indent: unit, insertion: attribute}), + ), + opening.selfClosing ? '/>' : '>', + ].join(endOfLine); + }; + + const printElement = ( + node: namedTypes.JSXElement | namedTypes.JSXFragment, + ): string => { + if (node.type === 'JSXFragment') { + const fragmentChildren = (node.children ?? []).flatMap((child) => { + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + return [printElement(child)]; + } + + if (child.type === 'JSXText' && child.value.trim() === '') { + return []; + } + + return [normalizeIndentation(printNode(child))]; + }); + return [ + '<>', + ...fragmentChildren.map((child) => + indentInsertedJsx({indent: unit, insertion: child}), + ), + '', + ].join(endOfLine); + } + + const opening = printOpeningElement(node.openingElement); + if (node.openingElement.selfClosing) { + return opening; + } + + const closing = node.closingElement + ? normalizeIndentation(printNode(node.closingElement)) + : ''; + const children = node.children ?? []; + if ( + children.length === 1 && + children[0].type === 'JSXText' && + !children[0].value.includes('\n') + ) { + return `${opening}${children[0].value}${closing}`; + } + + const printedChildren = children.flatMap((child) => { + if (child.type === 'JSXElement' || child.type === 'JSXFragment') { + return [printElement(child)]; + } + + if (child.type === 'JSXText' && child.value.trim() === '') { + return []; + } + + return [normalizeIndentation(printNode(child))]; + }); + if (printedChildren.length === 0) { + return `${opening}${closing}`; + } + + return [ + opening, + ...printedChildren.map((child) => + indentInsertedJsx({indent: unit, insertion: child}), + ), + closing, + ].join(endOfLine); + }; + + return printElement(element); +}; + +const getInsertionSource = ({ + element, + elementToInsert, + finalElementToInsert, + input, + prettierConfigOverride, + sequenceWrapper, +}: { + element: InsertableCompositionElement; + elementToInsert: namedTypes.JSXElement; + finalElementToInsert: namedTypes.JSXElement; + input: string; + prettierConfigOverride: Record | null; + sequenceWrapper: { + dimensions: {width: number; height: number} | null; + durationInFrames: number | null; + from: number | null; + name: string | null; + position: InsertableCompositionElementPosition | null; + } | null; +}) => { + if (element.type === 'solid') { + return getSolidInsertionSource({ + element: elementToInsert, + finalElement: finalElementToInsert, + height: element.height, + input, + position: element.position, + prettierConfigOverride, + sequenceWrapper, + width: element.width, + }); + } + + return printInsertedJsx({ + element: finalElementToInsert, + input, + prettierConfigOverride, + }); +}; + +const getInsertionRootSourceEdit = ({ + input, + insertion, + nullRoot, + prettierConfigOverride, + root, + sequenceLocalName, +}: { + input: string; + insertion: string; + nullRoot: NullLiteral | null; + prettierConfigOverride: Record | null; + root: namedTypes.JSXElement | namedTypes.JSXFragment | null; + sequenceLocalName: string | null; +}): SourceEdit => { + const endOfLine = input.includes('\r\n') ? '\r\n' : '\n'; + const unit = getIndentationUnit(input, prettierConfigOverride); + + if (nullRoot) { + if (!nullRoot.loc) { + throw new Error('Could not locate the null component root'); + } + + const nullStart = recastLocToOffset(input, nullRoot.loc.start); + const nullEnd = recastLocToOffset(input, nullRoot.loc.end); + const nullIndent = getLineIndent(input, nullStart); + return { + end: nullEnd, + replacement: [ + '(', + `${nullIndent}${unit}<>`, + indentInsertedJsx({ + indent: `${nullIndent}${unit}${unit}`, + insertion, + }), + `${nullIndent}${unit}`, + `${nullIndent})`, + ].join(endOfLine), + start: nullStart, + }; + } + + if (!root?.loc) { + throw new Error('Could not locate the composition component root'); + } + + if (root.type === 'JSXFragment') { + if (!root.closingFragment.loc) { + throw new Error('Could not locate the composition fragment closing tag'); + } + + const closingStart = recastLocToOffset( + input, + root.closingFragment.loc.start, + ); + const lineStart = input.lastIndexOf('\n', closingStart - 1) + 1; + const beforeClosing = input.slice(lineStart, closingStart); + const closingIndent = /^\s*$/.test(beforeClosing) + ? beforeClosing + : getLineIndent(input, recastLocToOffset(input, root.loc.start)); + if (/^\s*$/.test(beforeClosing) && lineStart > 0) { + return { + end: closingStart, + replacement: `${indentInsertedJsx({ + indent: `${closingIndent}${unit}`, + insertion, + })}${endOfLine}${closingIndent}`, + start: lineStart, + }; + } + + return { + end: closingStart, + replacement: `${endOfLine}${indentInsertedJsx({ + indent: `${closingIndent}${unit}`, + insertion, + })}${endOfLine}${closingIndent}`, + start: closingStart, + }; + } + + const start = recastLocToOffset(input, root.loc.start); + const end = recastLocToOffset(input, root.loc.end); + const indent = getLineIndent(input, start); + const original = input.slice(start, end); + const existingRoot = root.openingElement.selfClosing + ? [ + `${indent}${unit}<${sequenceLocalName}>`, + indentExistingJsx({ + indent: `${indent}${unit}${unit}`, + original, + originalIndent: indent, + }), + `${indent}${unit}`, + ] + : [ + indentExistingJsx({ + indent: `${indent}${unit}`, + original, + originalIndent: indent, + }), + ]; + + if (root.openingElement.selfClosing && sequenceLocalName === null) { + throw new Error('Expected a Sequence import for a self-closing root'); + } + + return { + end, + replacement: [ + '<>', + ...existingRoot, + indentInsertedJsx({indent: `${indent}${unit}`, insertion}), + `${indent}`, + ].join(endOfLine), + start, + }; +}; + +const applySourceEdits = ({ + edits, + input, +}: { + edits: SourceEdit[]; + input: string; +}) => { + const sorted = edits.slice().sort((left, right) => right.start - left.start); + let output = input; + let previousStart = input.length + 1; + for (const edit of sorted) { + if (edit.end > previousStart) { + throw new Error('Overlapping JSX insertion source ranges'); + } + + output = + output.slice(0, edit.start) + edit.replacement + output.slice(edit.end); + previousStart = edit.start; + } + + return output; +}; + const canAddSequenceToComponent = ({ ast, exportName, @@ -2541,6 +3374,27 @@ export const insertJsxElementIntoComposition = async ({ }); const ast = parseAst(input); const capturedNodePaths = captureJsxNodePaths(ast); + const componentDeclaration = getDeclarationByExportName({ + ast, + exportName: location.exportName, + }); + const rootBeforeInsertion = componentDeclaration + ? getComponentRootNode(componentDeclaration) + : null; + const nullRootBeforeInsertion = componentDeclaration + ? getNullComponentRoot(componentDeclaration) + : null; + const importSnapshots: ImportSnapshot[] = ast.program.body.flatMap( + (statement) => + statement.type === 'ImportDeclaration' + ? [ + { + declaration: statement, + specifiers: [...(statement.specifiers ?? [])], + }, + ] + : [], + ); if ( element.type === 'composition' && element.compositionId === compositionId @@ -2593,12 +3447,48 @@ export const insertJsxElementIntoComposition = async ({ exportName: location.exportName, element: finalElementToInsert, }); - const finalFile = serializeAst(ast); - - const {output, formatted} = await environment.formatFile({ - contents: finalFile, - prettierConfigOverride, + const finalRoot = componentDeclaration + ? getComponentRootNode(componentDeclaration) + : null; + const firstFinalRootChild = + finalRoot?.type === 'JSXFragment' + ? (finalRoot.children?.[0] ?? null) + : null; + const sequenceLocalName = + rootBeforeInsertion?.type === 'JSXElement' && + rootBeforeInsertion.openingElement.selfClosing && + firstFinalRootChild?.type === 'JSXElement' && + firstFinalRootChild.openingElement.name.type === 'JSXIdentifier' + ? firstFinalRootChild.openingElement.name.name + : null; + const output = applySourceEdits({ + edits: [ + ...getInsertImportSourceEdits({ + ast, + input, + prettierConfigOverride, + snapshots: importSnapshots, + }), + getInsertionRootSourceEdit({ + input, + insertion: getInsertionSource({ + element, + elementToInsert, + finalElementToInsert, + input, + prettierConfigOverride, + sequenceWrapper, + }), + nullRoot: nullRootBeforeInsertion, + prettierConfigOverride, + root: rootBeforeInsertion, + sequenceLocalName, + }), + ], + input, }); + const formatted = true; + const {finalNodePathByNode, nodePathRemappings} = getNodePathRemappings({ ast, captured: capturedNodePaths, diff --git a/packages/studio-codemods/src/split-video-from-audio.ts b/packages/studio-codemods/src/split-video-from-audio.ts index 568f3b139bc..a03117dc0d9 100644 --- a/packages/studio-codemods/src/split-video-from-audio.ts +++ b/packages/studio-codemods/src/split-video-from-audio.ts @@ -8,6 +8,7 @@ import type { Node, ReturnStatement, } from '@babel/types'; +import type {SequenceNodePathRemapping} from '@remotion/studio-shared'; import * as recast from 'recast'; import type {SequenceNodePath} from 'remotion'; import { @@ -208,10 +209,7 @@ export const splitVideoFromAudio = async ({ formatted: boolean; nodeLabel: string; logLine: number; - nodePathRemappings: Array<{ - oldNodePath: SequenceNodePath; - newNodePath: SequenceNodePath | null; - }>; + nodePathRemappings: SequenceNodePathRemapping[]; }> => { const ast = parseAst(input); const capturedNodePaths = captureJsxNodePaths(ast); diff --git a/packages/studio-codemods/src/test/insert-solid.test.ts b/packages/studio-codemods/src/test/insert-solid.test.ts index decb97e394e..1847b18f76b 100644 --- a/packages/studio-codemods/src/test/insert-solid.test.ts +++ b/packages/studio-codemods/src/test/insert-solid.test.ts @@ -1,5 +1,8 @@ import {expect, test} from 'bun:test'; -import {insertSolidIntoSource} from '..'; +import { + insertJsxElementIntoProjectWithNodePathRemappings, + insertSolidIntoSource, +} from '..'; test('inserts a Solid into a component source file', () => { const result = insertSolidIntoSource({ @@ -58,3 +61,148 @@ test('inserts a Solid as a sibling of a component root', () => { expect(rootEnd).toBeGreaterThan(-1); expect(solidStart).toBeGreaterThan(rootEnd); }); + +test('the Add Solid insertion preserves source formatting without calling Prettier', async () => { + const source = `import {Composition, AbsoluteFill} from 'remotion'; + +// Keep the deliberately non-Prettier formatting in this file. +export const MyComposition = () => { + return ( + +
Existing
+
+ ) +} + +export const Root = () => ; +`; + let formatCalls = 0; + const result = await insertJsxElementIntoProjectWithNodePathRemappings({ + formatFile: () => { + formatCalls++; + throw new Error('Prettier should not be called when inserting a Solid'); + }, + project: { + files: {'/project/src/index.tsx': source}, + rootDir: '/project', + }, + request: { + compositionFile: '/project/src/index.tsx', + compositionId: 'MyComp', + element: { + height: 720, + position: null, + type: 'solid', + width: 1280, + }, + from: null, + }, + svgMarkupToJsx: () => { + throw new Error( + 'SVG conversion should not be called when inserting a Solid', + ); + }, + wrapInSequence: null, + }); + + expect(formatCalls).toBe(0); + expect(result.project.files['/project/src/index.tsx']) + .toBe(`import {Composition, AbsoluteFill, Solid} from 'remotion'; + +// Keep the deliberately non-Prettier formatting in this file. +export const MyComposition = () => { + return ( + <> + +
Existing
+
+ + + ) +} + +export const Root = () => ; +`); + expect(result.insertedNodePath).not.toBeNull(); +}); + +test('asset and component insertions also avoid the full-file formatter', async () => { + const source = `import {Composition, AbsoluteFill} from 'remotion'; + +export const MyComposition = () => <>; +export const Root = () => ; +`; + let formatCalls = 0; + const assetResult = await insertJsxElementIntoProjectWithNodePathRemappings({ + formatFile: () => { + formatCalls++; + throw new Error('The full-file formatter should not be called'); + }, + project: { + files: {'/project/src/index.tsx': source}, + rootDir: '/project', + }, + request: { + compositionFile: '/project/src/index.tsx', + compositionId: 'MyComp', + element: { + assetType: 'image', + dimensions: {height: 720, width: 1280}, + durationInFrames: null, + position: null, + src: 'image.png', + srcType: 'static', + type: 'asset', + }, + from: null, + }, + svgMarkupToJsx: () => { + throw new Error('SVG conversion should not be called'); + }, + wrapInSequence: null, + }); + const componentResult = + await insertJsxElementIntoProjectWithNodePathRemappings({ + formatFile: () => { + formatCalls++; + throw new Error('The full-file formatter should not be called'); + }, + project: { + files: {'/project/src/index.tsx': source}, + rootDir: '/project', + }, + request: { + compositionFile: '/project/src/index.tsx', + compositionId: 'MyComp', + element: { + componentName: 'Chart', + importName: 'Chart', + importPath: './Chart', + position: null, + props: [{name: 'title', value: 'Revenue'}], + type: 'component', + }, + from: null, + }, + svgMarkupToJsx: () => { + throw new Error('SVG conversion should not be called'); + }, + wrapInSequence: null, + }); + + expect(formatCalls).toBe(0); + expect(assetResult.project.files['/project/src/index.tsx']).toContain( + ' { @@ -17,6 +18,7 @@ export const captureJsxNodePaths = (ast: File): CapturedJsxNodePath[] => { captured.push({ node: path.node as JSXOpeningElement, nodePath: getNodePathForRecastPath(path, ast), + signature: recast.prettyPrint(path.node as JSXOpeningElement).code, }); return this.traverse(path); }, @@ -63,17 +65,34 @@ export const getNodePathRemappings = ({ finalNodePathByNode.set(nodesAfterMutation[i], finalNodePaths[i]); } - const nodePathRemappings = captured.flatMap(({node, nodePath}) => { - const newNodePath = finalNodePathByNode.get(node) ?? null; - if ( - newNodePath !== null && - JSON.stringify(nodePath) === JSON.stringify(newNodePath) - ) { - return []; + const capturedNodes = new Set(captured.map(({node}) => node)); + const nodePathRemappings: SequenceNodePathRemapping[] = captured.flatMap( + ({node, nodePath, signature}) => { + const newNodePath = finalNodePathByNode.get(node) ?? null; + if ( + newNodePath !== null && + JSON.stringify(nodePath) === JSON.stringify(newNodePath) && + recast.prettyPrint(node).code === signature + ) { + return []; + } + + return [{oldNodePath: nodePath, newNodePath}]; + }, + ); + + for (const node of nodesAfterMutation) { + if (capturedNodes.has(node)) { + continue; } - return [{oldNodePath: nodePath, newNodePath}]; - }); + const newNodePath = finalNodePathByNode.get(node); + if (!newNodePath) { + throw new Error('Could not map inserted JSX node path'); + } + + nodePathRemappings.push({oldNodePath: null, newNodePath}); + } return {finalNodePathByNode, nodePathRemappings}; }; diff --git a/packages/studio-server/src/preview-server/api-routes.ts b/packages/studio-server/src/preview-server/api-routes.ts index 11f3250e923..748f3b78313 100644 --- a/packages/studio-server/src/preview-server/api-routes.ts +++ b/packages/studio-server/src/preview-server/api-routes.ts @@ -39,6 +39,7 @@ import {prepareElementInstallHandler} from './routes/prepare-element-install'; import {projectInfoHandler} from './routes/project-info'; import {redoHandler} from './routes/redo'; import {registerClientRenderHandler} from './routes/register-client-render'; +import {getReleaseNotesHandler} from './routes/release-notes'; import {remotionSkillsInfoHandler} from './routes/remotion-skills-info'; import {handleRemoveRender} from './routes/remove-render'; import {renameStaticFileHandler} from './routes/rename-static-file'; @@ -118,6 +119,7 @@ export const allApiRoutes: { '/api/split-jsx-sequence': splitJsxSequenceHandler, '/api/split-video-from-audio': splitVideoFromAudioHandler, '/api/update-available': handleUpdate, + '/api/release-notes': getReleaseNotesHandler, '/api/remotion-skills-info': remotionSkillsInfoHandler, '/api/project-info': projectInfoHandler, '/api/delete-static-file': deleteStaticFileHandler, diff --git a/packages/studio-server/src/preview-server/routes/delete-jsx-node.ts b/packages/studio-server/src/preview-server/routes/delete-jsx-node.ts index 385b2c7fcca..a3bec79ef3d 100644 --- a/packages/studio-server/src/preview-server/routes/delete-jsx-node.ts +++ b/packages/studio-server/src/preview-server/routes/delete-jsx-node.ts @@ -93,7 +93,6 @@ export const deleteJsxNodeHandler: ApiHandler< updates.map((update) => ({ absolutePath: update.absolutePath, remappings: update.nodePathRemappings, - restoredNodePaths: [], })), ); diff --git a/packages/studio-server/src/preview-server/routes/duplicate-jsx-node.ts b/packages/studio-server/src/preview-server/routes/duplicate-jsx-node.ts index af1be9c4b42..68ce824bba1 100644 --- a/packages/studio-server/src/preview-server/routes/duplicate-jsx-node.ts +++ b/packages/studio-server/src/preview-server/routes/duplicate-jsx-node.ts @@ -73,7 +73,6 @@ export const duplicateJsxNodeHandler: ApiHandler< updates.map((update) => ({ absolutePath: update.absolutePath, remappings: update.nodePathRemappings, - restoredNodePaths: [], })), ); const duplicatedNodeDescription = diff --git a/packages/studio-server/src/preview-server/routes/insert-element.ts b/packages/studio-server/src/preview-server/routes/insert-element.ts index f8fe4dde2eb..254871d7412 100644 --- a/packages/studio-server/src/preview-server/routes/insert-element.ts +++ b/packages/studio-server/src/preview-server/routes/insert-element.ts @@ -207,7 +207,6 @@ export const insertElementHandler: ApiHandler< { absolutePath: inserted.fileName, remappings: inserted.nodePathRemappings, - restoredNodePaths: [], }, ]); diff --git a/packages/studio-server/src/preview-server/routes/insert-jsx-element.ts b/packages/studio-server/src/preview-server/routes/insert-jsx-element.ts index 16b4f667f0a..0fb6135ce44 100644 --- a/packages/studio-server/src/preview-server/routes/insert-jsx-element.ts +++ b/packages/studio-server/src/preview-server/routes/insert-jsx-element.ts @@ -264,7 +264,6 @@ export const insertJsxElementHandler: ApiHandler< { absolutePath: fileName, remappings: nodePathRemappings, - restoredNodePaths: [], }, ]); if (insertedNodePath === null) { diff --git a/packages/studio-server/src/preview-server/routes/release-notes.ts b/packages/studio-server/src/preview-server/routes/release-notes.ts new file mode 100644 index 00000000000..aa27485726a --- /dev/null +++ b/packages/studio-server/src/preview-server/routes/release-notes.ts @@ -0,0 +1,165 @@ +import type { + GetReleaseNotesRequest, + GetReleaseNotesResponse, +} from '@remotion/studio-shared'; +import semver from 'semver'; +import type {ApiHandler} from '../api-types'; + +const githubApiVersion = '2022-11-28'; +const releaseNotesTimeout = 5000; +const releaseNotesCacheDuration = 5 * 60 * 1000; +const maximumReleaseNotes = 5; +const releaseNotesCache = new Map< + string, + { + expiresAt: number; + response: GetReleaseNotesResponse; + } +>(); + +export const getReleaseNotesHandler: ApiHandler< + GetReleaseNotesRequest, + GetReleaseNotesResponse +> = async ({input}) => { + const currentVersion = semver.valid(input.currentVersion); + const latestVersion = semver.valid(input.latestVersion); + if (currentVersion === null || latestVersion === null) { + throw new Error( + `Invalid Remotion version range: ${input.currentVersion} to ${input.latestVersion}`, + ); + } + + const cacheKey = `${currentVersion}:${latestVersion}`; + const cachedReleaseNotes = releaseNotesCache.get(cacheKey); + if (cachedReleaseNotes && cachedReleaseNotes.expiresAt > Date.now()) { + return cachedReleaseNotes.response; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), releaseNotesTimeout); + + try { + const releaseResponse = await fetch( + 'https://api.github.com/repos/remotion-dev/remotion/releases?per_page=100', + { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': 'Remotion Studio', + 'x-github-api-version': githubApiVersion, + }, + signal: controller.signal, + }, + ); + + if (!releaseResponse.ok) { + return {hasMore: false, releases: []}; + } + + const githubReleases = (await releaseResponse.json()) as unknown; + if (!Array.isArray(githubReleases)) { + return {hasMore: false, releases: []}; + } + + const matchingReleases = githubReleases + .flatMap((release) => { + if (typeof release !== 'object' || release === null) { + return []; + } + + const { + body, + published_at: publishedAt, + tag_name: tagName, + } = release as Record; + if (typeof tagName !== 'string') { + return []; + } + + const version = semver.valid( + tagName.startsWith('v') ? tagName.slice(1) : tagName, + ); + if ( + version === null || + !semver.gt(version, currentVersion) || + !semver.lte(version, latestVersion) + ) { + return []; + } + + return [ + { + body: typeof body === 'string' ? body : null, + publishedAt: typeof publishedAt === 'string' ? publishedAt : null, + version, + }, + ]; + }) + .sort((a, b) => semver.rcompare(a.version, b.version)); + + const hasMore = matchingReleases.length > maximumReleaseNotes; + const releases = await Promise.all( + matchingReleases.slice(0, maximumReleaseNotes).map(async (release) => { + if (release.body === null || release.body.trim() === '') { + return { + publishedAt: release.publishedAt, + releaseNotesHtml: null, + version: release.version, + }; + } + + try { + const markdownResponse = await fetch( + 'https://api.github.com/markdown', + { + body: JSON.stringify({ + context: 'remotion-dev/remotion', + mode: 'gfm', + text: release.body, + }), + headers: { + accept: 'text/html', + 'content-type': 'application/json', + 'user-agent': 'Remotion Studio', + 'x-github-api-version': githubApiVersion, + }, + method: 'POST', + signal: controller.signal, + }, + ); + + if (!markdownResponse.ok) { + return { + publishedAt: release.publishedAt, + releaseNotesHtml: null, + version: release.version, + }; + } + + const releaseNotesHtml = await markdownResponse.text(); + return { + publishedAt: release.publishedAt, + releaseNotesHtml: releaseNotesHtml || null, + version: release.version, + }; + } catch { + return { + publishedAt: release.publishedAt, + releaseNotesHtml: null, + version: release.version, + }; + } + }), + ); + + const response = {hasMore, releases}; + releaseNotesCache.set(cacheKey, { + expiresAt: Date.now() + releaseNotesCacheDuration, + response, + }); + return response; + } catch { + return {hasMore: false, releases: []}; + } finally { + clearTimeout(timeout); + } +}; diff --git a/packages/studio-server/src/preview-server/routes/reorder-sequence.ts b/packages/studio-server/src/preview-server/routes/reorder-sequence.ts index b3092dca191..1b6c5030ec5 100644 --- a/packages/studio-server/src/preview-server/routes/reorder-sequence.ts +++ b/packages/studio-server/src/preview-server/routes/reorder-sequence.ts @@ -55,7 +55,6 @@ export const reorderSequenceHandler: ApiHandler< { absolutePath, remappings: nodePathRemappings, - restoredNodePaths: [], }, ]); diff --git a/packages/studio-server/src/preview-server/routes/split-jsx-sequence.ts b/packages/studio-server/src/preview-server/routes/split-jsx-sequence.ts index 29905fcf5e0..93995bfdeaa 100644 --- a/packages/studio-server/src/preview-server/routes/split-jsx-sequence.ts +++ b/packages/studio-server/src/preview-server/routes/split-jsx-sequence.ts @@ -54,7 +54,6 @@ export const splitJsxSequenceHandler: ApiHandler< { absolutePath, remappings: nodePathRemappings, - restoredNodePaths: [], }, ]); diff --git a/packages/studio-server/src/preview-server/routes/split-video-from-audio.ts b/packages/studio-server/src/preview-server/routes/split-video-from-audio.ts index 55b38359249..fd5ed483ef7 100644 --- a/packages/studio-server/src/preview-server/routes/split-video-from-audio.ts +++ b/packages/studio-server/src/preview-server/routes/split-video-from-audio.ts @@ -48,7 +48,6 @@ export const splitVideoFromAudioHandler: ApiHandler< { absolutePath, remappings: nodePathRemappings, - restoredNodePaths: [], }, ]); diff --git a/packages/studio-server/src/preview-server/sequence-node-path-mutation.ts b/packages/studio-server/src/preview-server/sequence-node-path-mutation.ts index dc527778ae7..475e23e367f 100644 --- a/packages/studio-server/src/preview-server/sequence-node-path-mutation.ts +++ b/packages/studio-server/src/preview-server/sequence-node-path-mutation.ts @@ -3,7 +3,6 @@ import type { SequenceNodePathMutation, SequenceNodePathRemapping, } from '@remotion/studio-shared'; -import type {SequenceNodePath} from 'remotion'; import {getLiveEventsListener} from './live-events'; const mutationSessionId = randomUUID(); @@ -13,7 +12,6 @@ export const broadcastSequenceNodePathMutation = ( files: Array<{ absolutePath: string; remappings: SequenceNodePathRemapping[]; - restoredNodePaths: SequenceNodePath[]; }>, ): SequenceNodePathMutation => { mutationCounter++; diff --git a/packages/studio-server/src/preview-server/undo-stack.ts b/packages/studio-server/src/preview-server/undo-stack.ts index 026b19bd434..f61068045ae 100644 --- a/packages/studio-server/src/preview-server/undo-stack.ts +++ b/packages/studio-server/src/preview-server/undo-stack.ts @@ -6,7 +6,6 @@ import type { SequenceNodePathRemapping, UndoResponse, } from '@remotion/studio-shared'; -import type {SequenceNodePath} from 'remotion'; import {parseAst} from '../codemods/parse-ast'; import {readVisualControlValues} from '../codemods/read-visual-control-values'; import { @@ -445,23 +444,11 @@ export function popUndo(): UndoResponse { return [ { absolutePath: snapshot.filePath, - remappings: snapshot.nodePathRemappings.flatMap( - (remapping): SequenceNodePathRemapping[] => { - if (remapping.newNodePath === null) { - return []; - } - - return [ - { - oldNodePath: remapping.newNodePath, - newNodePath: remapping.oldNodePath, - }, - ]; - }, - ), - restoredNodePaths: snapshot.nodePathRemappings.flatMap( - (remapping): SequenceNodePath[] => - remapping.newNodePath === null ? [remapping.oldNodePath] : [], + remappings: snapshot.nodePathRemappings.map( + (remapping): SequenceNodePathRemapping => ({ + oldNodePath: remapping.newNodePath, + newNodePath: remapping.oldNodePath, + }), ), }, ]; @@ -555,7 +542,6 @@ export function popRedo(): RedoResponse { { absolutePath: snapshot.filePath, remappings: snapshot.nodePathRemappings, - restoredNodePaths: [], }, ]; }); diff --git a/packages/studio-server/src/test/delete-jsx-node.test.ts b/packages/studio-server/src/test/delete-jsx-node.test.ts index 75c96f6610c..6aadc044fe7 100644 --- a/packages/studio-server/src/test/delete-jsx-node.test.ts +++ b/packages/studio-server/src/test/delete-jsx-node.test.ts @@ -445,7 +445,6 @@ test('deleting a JSX node broadcasts node path mutations for all clients', async newNodePath: lineColumnToNodePath(output, 7), }, ], - restoredNodePaths: [], }, ]); expect( @@ -475,6 +474,10 @@ test('deleting a JSX node broadcasts node path mutations for all clients', async { absolutePath: filePath, remappings: [ + { + oldNodePath: null, + newNodePath: lineColumnToNodePath(interactiveSiblings, 6), + }, { oldNodePath: lineColumnToNodePath(output, 6), newNodePath: lineColumnToNodePath(interactiveSiblings, 7), @@ -484,7 +487,6 @@ test('deleting a JSX node broadcasts node path mutations for all clients', async newNodePath: lineColumnToNodePath(interactiveSiblings, 8), }, ], - restoredNodePaths: [lineColumnToNodePath(interactiveSiblings, 6)], }, ]); expect( diff --git a/packages/studio-server/src/test/duplicate-jsx-node.test.ts b/packages/studio-server/src/test/duplicate-jsx-node.test.ts index 08ca2dcec83..80611303093 100644 --- a/packages/studio-server/src/test/duplicate-jsx-node.test.ts +++ b/packages/studio-server/src/test/duplicate-jsx-node.test.ts @@ -31,20 +31,24 @@ test('duplicateJsxNode inserts a sibling JSX element', async () => { test('duplicateJsxNode remaps following JSX siblings', async () => { const input = `export const X = () => (
- - + +
); `; const {output, nodePathRemappings} = await duplicateJsxNode({ input, - nodePath: lineContainingToNodePath(input, 'data-name="duplicate"'), + nodePath: lineContainingToNodePath(input, 'name="duplicate"'), }); expect(nodePathRemappings).toEqual([ { - oldNodePath: lineContainingToNodePath(input, 'data-name="following"'), - newNodePath: lineContainingToNodePath(output, 'data-name="following"'), + oldNodePath: lineContainingToNodePath(input, 'name="following"'), + newNodePath: lineContainingToNodePath(output, 'name="following"'), + }, + { + oldNodePath: null, + newNodePath: lineContainingToNodePath(output, 'name="duplicate-copy"'), }, ]); }); diff --git a/packages/studio-server/src/test/insert-element.test.ts b/packages/studio-server/src/test/insert-element.test.ts index 630c1ed6591..f8dcc79577c 100644 --- a/packages/studio-server/src/test/insert-element.test.ts +++ b/packages/studio-server/src/test/insert-element.test.ts @@ -316,8 +316,8 @@ test('installs an Element with a component-owned Sequence', async () => { expect(composition).toContain('durationInFrames={72}'); expect(composition).toContain('from={30}'); expect(composition).toContain('name="Lower Third"'); - expect(composition).toContain("position: 'absolute'"); - expect(composition).toContain("translate: '120px 80px'"); + expect(composition).toContain('position: "absolute"'); + expect(composition).toContain('translate: "120px 80px"'); } finally { fixture.cleanup(); } diff --git a/packages/studio-server/src/test/jsx-node-path-mutation-routes.test.ts b/packages/studio-server/src/test/jsx-node-path-mutation-routes.test.ts index e4e74d58f9a..04e2e15e6c5 100644 --- a/packages/studio-server/src/test/jsx-node-path-mutation-routes.test.ts +++ b/packages/studio-server/src/test/jsx-node-path-mutation-routes.test.ts @@ -110,7 +110,19 @@ test('JSX structure routes broadcast and return node path mutations before writi watcherSkipSequencePropsUpdates.length = 0; }; + const invertMutationFiles = ( + files: SequenceNodePathMutation['files'], + ): SequenceNodePathMutation['files'] => + files.map((file) => ({ + absolutePath: file.absolutePath, + remappings: file.remappings.map((remapping) => ({ + oldNodePath: remapping.newNodePath, + newNodePath: remapping.oldNodePath, + })), + })); + try { + const forwardMutationFiles: SequenceNodePathMutation['files'][] = []; let before = readFileSync(filePath, 'utf-8'); const subscriptionKey = (search: string) => ({ absolutePath: filePath, @@ -135,6 +147,13 @@ test('JSX structure routes broadcast and return node path mutations before writi } assertMutation({before, mutation: reorderResponse.nodePathMutation}); + forwardMutationFiles.push(reorderResponse.nodePathMutation.files); + expect( + reorderResponse.nodePathMutation.files[0].remappings.every( + (remapping) => + remapping.oldNodePath !== null && remapping.newNodePath !== null, + ), + ).toBe(true); before = readFileSync(filePath, 'utf-8'); const duplicateResponse = await duplicateJsxNodeHandler({ @@ -157,6 +176,13 @@ test('JSX structure routes broadcast and return node path mutations before writi } assertMutation({before, mutation: duplicateResponse.nodePathMutation}); + forwardMutationFiles.push(duplicateResponse.nodePathMutation.files); + expect( + duplicateResponse.nodePathMutation.files[0].remappings.some( + (remapping) => + remapping.oldNodePath === null && remapping.newNodePath !== null, + ), + ).toBe(true); expect(readFileSync(filePath, 'utf-8')).toContain('name="b-copy"'); expect(readFileSync(filePath, 'utf-8')).toContain('name="c-copy"'); @@ -175,6 +201,13 @@ test('JSX structure routes broadcast and return node path mutations before writi } assertMutation({before, mutation: splitResponse.nodePathMutation}); + forwardMutationFiles.push(splitResponse.nodePathMutation.files); + expect( + splitResponse.nodePathMutation.files[0].remappings.some( + (remapping) => + remapping.oldNodePath === null && remapping.newNodePath !== null, + ), + ).toBe(true); before = readFileSync(filePath, 'utf-8'); const insertResponse = await insertJsxElementHandler({ @@ -196,6 +229,7 @@ test('JSX structure routes broadcast and return node path mutations before writi } assertMutation({before, mutation: insertResponse.nodePathMutation}); + forwardMutationFiles.push(insertResponse.nodePathMutation.files); for (let i = 0; i < 4; i++) { before = readFileSync(filePath, 'utf-8'); @@ -204,6 +238,9 @@ test('JSX structure routes broadcast and return node path mutations before writi throw new Error('Expected undo to include a node path mutation'); } + expect(undoResponse.nodePathMutation.files).toEqual( + invertMutationFiles(forwardMutationFiles[3 - i]), + ); assertMutation({before, mutation: undoResponse.nodePathMutation}); } @@ -216,6 +253,9 @@ test('JSX structure routes broadcast and return node path mutations before writi throw new Error('Expected redo to include a node path mutation'); } + expect(redoResponse.nodePathMutation.files).toEqual( + forwardMutationFiles[i], + ); assertMutation({before, mutation: redoResponse.nodePathMutation}); } } finally { diff --git a/packages/studio-server/src/test/release-notes.test.ts b/packages/studio-server/src/test/release-notes.test.ts new file mode 100644 index 00000000000..ccce52eb842 --- /dev/null +++ b/packages/studio-server/src/test/release-notes.test.ts @@ -0,0 +1,176 @@ +import {expect, test} from 'bun:test'; +import type {IncomingMessage, ServerResponse} from 'node:http'; +import {getReleaseNotesHandler} from '../preview-server/routes/release-notes'; + +const callHandler = (currentVersion: string, latestVersion: string) => { + return getReleaseNotesHandler({ + binariesDirectory: null, + configFile: null, + entryPoint: '', + getDefaultCodingAgent: () => null, + getDefaultEditor: () => null, + input: {currentVersion, latestVersion}, + logLevel: 'info', + methods: { + addJob: () => undefined, + cancelJob: () => undefined, + removeJob: () => undefined, + }, + publicDir: '', + remotionRoot: '', + request: {} as IncomingMessage, + response: {} as ServerResponse, + }); +}; + +test('renders and caches every missed release with GitHub-flavored Markdown', async () => { + const originalFetch = globalThis.fetch; + const requests: {body: string | null; url: string}[] = []; + + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: RequestInit) => { + const body = typeof init?.body === 'string' ? init.body : null; + requests.push({body, url: input.toString()}); + + if (body === null) { + return Promise.resolve( + Response.json([ + {body: '## Future', tag_name: 'v4.0.519'}, + { + body: '## Latest', + published_at: '2026-08-27T12:00:00Z', + tag_name: 'v4.0.518', + }, + { + body: '## Earlier', + published_at: '2026-08-20T12:00:00Z', + tag_name: 'v4.0.517', + }, + {body: '## Installed', tag_name: 'v4.0.516'}, + ]), + ); + } + + const {text} = JSON.parse(body) as {text: string}; + return Promise.resolve(new Response(`

${text.slice(3)}

`)); + }, + {preconnect: originalFetch.preconnect}, + ); + + try { + const expected = { + hasMore: false, + releases: [ + { + publishedAt: '2026-08-27T12:00:00Z', + releaseNotesHtml: '

Latest

', + version: '4.0.518', + }, + { + publishedAt: '2026-08-20T12:00:00Z', + releaseNotesHtml: '

Earlier

', + version: '4.0.517', + }, + ], + }; + await expect(callHandler('4.0.516', '4.0.518')).resolves.toEqual(expected); + await expect(callHandler('4.0.516', '4.0.518')).resolves.toEqual(expected); + expect(requests).toEqual([ + { + body: null, + url: 'https://api.github.com/repos/remotion-dev/remotion/releases?per_page=100', + }, + { + body: JSON.stringify({ + context: 'remotion-dev/remotion', + mode: 'gfm', + text: '## Latest', + }), + url: 'https://api.github.com/markdown', + }, + { + body: JSON.stringify({ + context: 'remotion-dev/remotion', + mode: 'gfm', + text: '## Earlier', + }), + url: 'https://api.github.com/markdown', + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('limits release notes to the five most recent releases', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: RequestInit) => { + if (typeof init?.body !== 'string') { + return Promise.resolve( + Response.json( + [513, 514, 515, 516, 517, 518].map((patch) => ({ + body: `## ${patch}`, + tag_name: `v4.0.${patch}`, + })), + ), + ); + } + + return Promise.resolve(new Response(input.toString())); + }, + {preconnect: originalFetch.preconnect}, + ); + + try { + const response = await callHandler('4.0.512', '4.0.518'); + expect(response.hasMore).toBe(true); + expect(response.releases.map(({version}) => version)).toEqual([ + '4.0.518', + '4.0.517', + '4.0.516', + '4.0.515', + '4.0.514', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('gracefully falls back when releases cannot be loaded', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + () => Promise.resolve(new Response(null, {status: 404})), + {preconnect: originalFetch.preconnect}, + ); + + try { + await expect(callHandler('4.0.500', '4.0.501')).resolves.toEqual({ + hasMore: false, + releases: [], + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('rejects invalid versions without calling GitHub', async () => { + const originalFetch = globalThis.fetch; + let didFetch = false; + globalThis.fetch = Object.assign( + () => { + didFetch = true; + return Promise.resolve(new Response()); + }, + {preconnect: originalFetch.preconnect}, + ); + + try { + await expect(callHandler('../../latest', '4.0.518')).rejects.toThrow( + 'Invalid Remotion version range', + ); + expect(didFetch).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/studio-server/src/test/resolve-composition-component.test.ts b/packages/studio-server/src/test/resolve-composition-component.test.ts index 57691d651b9..7f01bd12513 100644 --- a/packages/studio-server/src/test/resolve-composition-component.test.ts +++ b/packages/studio-server/src/test/resolve-composition-component.test.ts @@ -465,11 +465,11 @@ test('wraps a self-closing root in a Sequence before inserting', async () => { }); expect(result.output).toContain( - "import { staticFile, Sequence } from 'remotion';", + "import {staticFile, Sequence} from 'remotion';", ); expect(result.output).toContain(''); expect(result.output).toContain( - "'); expect(result.output).toContain("