import { AssetRecordType, TLBookmarkShape, createShapeId, getHashForString } from '@tldraw/editor' import { vi } from 'vitest' import { defaultHandleExternalUrlAsset } from '../lib/defaultExternalContentHandlers' import { createBookmarkFromUrl, getBookmarkShapeHeight, getHumanReadableAddress, getResolvedBookmarkAssetId, } from '../lib/shapes/bookmark/bookmarks' import { TestEditor } from './TestEditor' let editor: TestEditor beforeEach(() => { editor = new TestEditor() }) afterEach(() => { editor?.dispose() }) describe('The URL formatter', () => { it('Formats URLs as human-readable', () => { const ids = { a: createShapeId(), b: createShapeId(), c: createShapeId(), d: createShapeId(), e: createShapeId(), f: createShapeId(), } editor.createShapes([ { id: ids.a, type: 'bookmark', props: { url: 'https://www.github.com', }, }, { id: ids.b, type: 'bookmark', props: { url: 'https://www.github.com/', }, }, { id: ids.c, type: 'bookmark', props: { url: 'https://www.github.com/TodePond', }, }, { id: ids.d, type: 'bookmark', props: { url: 'https://www.github.com/TodePond/', }, }, { id: ids.e, type: 'bookmark', props: { url: 'https://www.github.com//', }, }, { id: ids.f, type: 'bookmark', props: { url: 'https://www.github.com/TodePond/DreamBerd//', }, }, ]) const a = editor.getShape(ids.a)! const b = editor.getShape(ids.b)! const c = editor.getShape(ids.c)! const d = editor.getShape(ids.d)! const e = editor.getShape(ids.e)! const f = editor.getShape(ids.f)! expect(getHumanReadableAddress(a.props.url)).toBe('github.com') expect(getHumanReadableAddress(b.props.url)).toBe('github.com') expect(getHumanReadableAddress(c.props.url)).toBe('github.com') expect(getHumanReadableAddress(d.props.url)).toBe('github.com') expect(getHumanReadableAddress(e.props.url)).toBe('github.com') expect(getHumanReadableAddress(f.props.url)).toBe('github.com') }) it("Doesn't resize bookmarks", () => { const ids = { bookmark: createShapeId(), boxA: createShapeId(), boxB: createShapeId(), } editor.createShapes([ { id: ids.bookmark, type: 'bookmark', props: { url: 'https://www.github.com/TodePond', }, }, { type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 10, h: 10, }, }, { type: 'geo', id: ids.boxB, x: 20, y: 20, props: { w: 10, h: 10, }, }, ]) const oldBookmark = editor.getShape(ids.bookmark) as TLBookmarkShape expect(oldBookmark.props.w).toBe(300) expect(oldBookmark.props.h).toBe(320) editor.select(ids.bookmark, ids.boxA, ids.boxB) editor.pointerDown(20, 20, { target: 'selection', handle: 'bottom_right' }) editor.pointerMove(30, 30) const newBookmark = editor.getShape(ids.bookmark) as TLBookmarkShape expect(newBookmark.props.w).toBe(300) expect(newBookmark.props.h).toBe(320) }) }) describe('createBookmarkFromUrl', () => { it('creates a placeholder bookmark shape immediately and patches the asset once metadata resolves', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } // Use a deferred promise so we can observe state before the asset resolves. let resolveAsset: (asset: any) => void = () => {} const assetPromise = new Promise((resolve) => { resolveAsset = resolve }) const mockAsset = { id: 'asset:test-asset-id' as any, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: 'https://example.com/image.jpg', favicon: 'https://example.com/favicon.ico', }, meta: {}, } vi.spyOn(editor, 'getAssetForExternalContent').mockReturnValue(assetPromise) const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value // Placeholder should exist before the asset resolves. expect(shape.type).toBe('bookmark') expect(shape.props.url).toBe(url) expect(shape.props.assetId).toBe(null) expect(shape.props.w).toBe(300) expect(shape.props.h).toBe(320) expect(shape.x).toBe(center.x - 150) expect(shape.y).toBe(center.y - 160) const placeholderInStore = editor.getShape(shape.id) expect(placeholderInStore?.props.assetId).toBe(null) expect(editor.getAsset('asset:test-asset-id' as any)).toBeUndefined() // Resolve the asset and let the microtask queue flush. resolveAsset(mockAsset) await assetPromise const hydrated = editor.getShape(shape.id) expect(hydrated?.props.assetId).toBe('asset:test-asset-id') const createdAsset = editor.getAsset('asset:test-asset-id' as any) expect(createdAsset).toBeDefined() expect(createdAsset?.type).toBe('bookmark') }) it('creates a bookmark shape with default center when no center provided', async () => { const url = 'https://example.com' const viewportCenter = { x: 500, y: 300 } vi.spyOn(editor, 'getViewportPageBounds').mockReturnValue({ x: 0, y: 0, w: 1000, h: 600, center: viewportCenter, } as any) vi.spyOn(editor, 'getAssetForExternalContent').mockResolvedValue({ id: 'asset:test-asset-id' as any, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: '', favicon: '', }, meta: {}, }) const result = await createBookmarkFromUrl(editor, { url }) assert(result.ok, 'Failed to create bookmark') const shape = result.value expect(shape.x).toBe(viewportCenter.x - 150) expect(shape.y).toBe(viewportCenter.y - 160) }) it('keeps the placeholder bookmark when the asset fetch rejects', async () => { const url = 'https://invalid-url.com' const center = { x: 100, y: 200 } const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(editor, 'getAssetForExternalContent').mockRejectedValue(new Error('Failed to fetch')) const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Expected placeholder bookmark to be created even when fetch fails') const shape = result.value expect(shape.type).toBe('bookmark') expect(shape.props.url).toBe(url) expect(shape.props.assetId).toBe(null) // Wait for the rejected promise to be observed. await Promise.resolve() await Promise.resolve() const stillPlaceholder = editor.getShape(shape.id) expect(stillPlaceholder?.props.assetId).toBe(null) consoleSpy.mockRestore() }) it('creates bookmark shape even when asset creation returns null', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } vi.spyOn(editor, 'getAssetForExternalContent').mockResolvedValue(null as any) const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value expect(shape.type).toBe('bookmark') expect(shape.props.url).toBe(url) expect(shape.props.assetId).toBe(null) const createdShape = editor.getShape(result.value.id) expect(createdShape).toBeDefined() }) it('reuses an existing bookmark asset for the same URL without re-fetching', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } let resolveFirst: (asset: any) => void = () => {} const firstPromise = new Promise((resolve) => { resolveFirst = resolve }) const fetchSpy = vi.spyOn(editor, 'getAssetForExternalContent') fetchSpy.mockReturnValueOnce(firstPromise) const first = await createBookmarkFromUrl(editor, { url, center }) assert(first.ok) resolveFirst({ id: AssetRecordType.createId(getHashForString(url)), typeName: 'asset', type: 'bookmark', props: { src: url, title: 'Example Site', description: 'desc', image: 'https://example.com/image.jpg', favicon: 'https://example.com/favicon.ico', }, meta: {}, }) await firstPromise // Yield once more so the hydrate continuation runs after the resolver. await Promise.resolve() const hydrated = editor.getShape(first.value.id) expect(hydrated?.props.assetId).not.toBeNull() expect(fetchSpy).toHaveBeenCalledTimes(1) // Second creation for the same URL should reuse the asset and not fetch again. const second = await createBookmarkFromUrl(editor, { url, center: { x: 400, y: 500 } }) assert(second.ok) expect(second.value.props.assetId).not.toBeNull() expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('does not patch the asset if the shape URL changed before metadata resolved', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } let resolveAsset: (asset: any) => void = () => {} const assetPromise = new Promise((resolve) => { resolveAsset = resolve }) vi.spyOn(editor, 'getAssetForExternalContent').mockReturnValue(assetPromise) const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value // Change the URL before the original fetch resolves. editor.updateShapes([{ id: shape.id, type: 'bookmark', props: { url: 'https://changed.com' } }]) // Now resolve the original fetch — its metadata is for the stale URL. resolveAsset({ id: 'asset:test-asset-id' as any, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: 'https://example.com/image.jpg', favicon: 'https://example.com/favicon.ico', }, meta: {}, }) await assetPromise await Promise.resolve() // The shape should keep its current (changed) URL and not be patched with // the stale asset. const after = editor.getShape(shape.id) expect(after?.props.url).toBe('https://changed.com') expect(after?.props.assetId).toBe(null) }) it('removes the placeholder cleanly on undo without an extra metadata undo step', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } const assetPromise = Promise.resolve({ id: 'asset:test-asset-id' as any, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: 'https://example.com/image.jpg', favicon: 'https://example.com/favicon.ico', }, meta: {}, }) vi.spyOn(editor, 'getAssetForExternalContent').mockReturnValue(assetPromise) editor.markHistoryStoppingPoint() const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value // Let hydration (history: 'ignore') run. await assetPromise await Promise.resolve() expect(editor.getShape(shape.id)?.props.assetId).toBe('asset:test-asset-id') // A single undo should remove the placeholder entirely; the metadata patch // must not have created its own undo step. editor.undo() expect(editor.getShape(shape.id)).toBeUndefined() }) it('renders the hydrated asset after redo even though the asset patch was history-ignored', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } const assetId = AssetRecordType.createId(getHashForString(url)) const assetPromise = Promise.resolve({ id: assetId, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: 'https://example.com/image.jpg', favicon: 'https://example.com/favicon.ico', }, meta: {}, }) vi.spyOn(editor, 'getAssetForExternalContent').mockReturnValue(assetPromise) editor.markHistoryStoppingPoint() const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value // Let hydration (history: 'ignore') run. await assetPromise await Promise.resolve() expect(editor.getShape(shape.id)?.props.assetId).toBe(assetId) // Undo removes the placeholder; the hydrated asset stays in the store // because it was created with history: 'ignore'. editor.undo() expect(editor.getShape(shape.id)).toBeUndefined() expect(editor.getAsset(assetId)).toBeDefined() // Redo restores the placeholder shape with a null assetId (the asset patch // was history-ignored). The shape should still resolve its asset by URL so // it doesn't stay stuck on the placeholder. editor.redo() const redone = editor.getShape(shape.id) assert(redone, 'Expected the bookmark to be restored on redo') expect(getResolvedBookmarkAssetId(editor, redone.props.assetId, redone.props.url)).toBe(assetId) }) it('measures a short bookmark at its resolved height after redo, not the placeholder height', async () => { const url = 'https://example.com' const center = { x: 100, y: 200 } const assetId = AssetRecordType.createId(getHashForString(url)) // A no-image unfurl produces a shorter bookmark than the placeholder. const assetPromise = Promise.resolve({ id: assetId, typeName: 'asset' as const, type: 'bookmark' as const, props: { src: url, title: 'Example Site', description: 'An example website', image: '', favicon: 'https://example.com/favicon.ico', }, meta: {}, }) vi.spyOn(editor, 'getAssetForExternalContent').mockReturnValue(assetPromise) editor.markHistoryStoppingPoint() const result = await createBookmarkFromUrl(editor, { url, center }) assert(result.ok, 'Failed to create bookmark') const shape = result.value await assetPromise await Promise.resolve() const hydrated = editor.getShape(shape.id) assert(hydrated, 'Expected the bookmark to hydrate') const shortHeight = hydrated.props.h expect(shortHeight).toBeLessThan(320) editor.undo() editor.redo() const redone = editor.getShape(shape.id) assert(redone, 'Expected the bookmark to be restored on redo') // props.h is stale (the history entry recorded the placeholder height), but // the effective height and the geometry/selection bounds should match the // resolved short bookmark, not the 320px placeholder. expect(getBookmarkShapeHeight(editor, redone)).toBe(shortHeight) expect(editor.getShapeGeometry(redone.id).bounds.height).toBe(shortHeight) }) }) describe('defaultHandleExternalUrlAsset', () => { const url = 'https://example.com/some/page' let opts: any let fetchMock: ReturnType beforeEach(() => { opts = { toasts: { addToast: vi.fn(), removeToast: vi.fn(), clearToasts: vi.fn() }, msg: (id: string) => id, } }) afterEach(() => { vi.unstubAllGlobals() }) function mockResponseBody(html: string) { fetchMock = vi.fn().mockResolvedValue({ text: async () => html }) vi.stubGlobal('fetch', fetchMock) } function mockPageHead(head: string) { mockResponseBody(`${head}`) } it('leaves the image and favicon empty when the page provides neither', async () => { mockPageHead('Example') const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) expect(asset.props.image).toBe('') expect(asset.props.favicon).toBe('') }) it('resolves relative image and favicon urls against the page', async () => { mockPageHead( '' + '' ) const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) expect(asset.props.image).toBe('https://example.com/img/preview.png') expect(asset.props.favicon).toBe('https://example.com/some/favicon.ico') }) it('leaves absolute image urls alone', async () => { mockPageHead('') const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) expect(asset.props.image).toBe('https://cdn.example.com/preview.png') }) it('toasts and returns a blank bookmark when the fetch throws', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) fetchMock = vi.fn().mockRejectedValue(new Error('CORS')) vi.stubGlobal('fetch', fetchMock) const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) expect(opts.toasts.addToast).toHaveBeenCalledWith( expect.objectContaining({ severity: 'error' }) ) expect(asset.props.src).toBe(url) expect(asset.props.image).toBe('') expect(asset.props.title).toBe('') consoleSpy.mockRestore() }) // Pins behaviour we'd like to change, so that changing it is deliberate. describe('behaviour to revisit', () => { it('extracts nothing at all from a cross-origin page', async () => { // `mode: 'no-cors'` makes the response opaque cross-origin, and an // opaque body always reads as ''. mockResponseBody('') const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ mode: 'no-cors' })) expect(asset.props.image).toBe('') expect(asset.props.favicon).toBe('') expect(asset.props.description).toBe('') expect(asset.props.title).toBe(url) expect(opts.toasts.addToast).not.toHaveBeenCalled() }) it('ignores twitter card tags, og:image:secure_url and ', async () => { mockPageHead( '<title>Example' + '' + '' + '' + '' ) const asset = await defaultHandleExternalUrlAsset(editor, { type: 'url', url }, opts) // cloudflare-workers-unfurl reads all four of these; this handler // reads only the plain `og:` tags. expect(asset.props.image).toBe('') expect(asset.props.description).toBe('') expect(asset.props.title).toBe(url) }) }) })