A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 8.8 kB View raw
1import { describe, it, expect, vi, beforeEach } from 'vitest'; 2import { act, createElement } from 'react'; 3import { createRoot } from 'react-dom/client'; 4import type { Agent } from '@atproto/api'; 5 6( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 8// Hoist the mock fns so vi.mock (which is hoisted) can reference them safely. 9const { updateDocument, publish } = vi.hoisted( () => { 10 return { 11 updateDocument: vi.fn( 12 async ( _agent: unknown, _identity: unknown, _input: unknown ) => ( { 13 documentUri: 'at://x', 14 articleUrl: 'https://x', 15 } ) 16 ), 17 publish: vi.fn( async () => ( { 18 publicationUri: 'at://p', 19 documentUri: 'at://d', 20 postUri: 'at://post', 21 articleUrl: 'https://x', 22 } ) ), 23 }; 24} ); 25 26// Mock the publish layer so we can assert what PublishPanel forwards. 27vi.mock( '../lib/publish/publisher', () => ( { publish, updateDocument } ) ); 28 29import PublishPanel, { computePostPreview } from './PublishPanel'; 30import type { BlockNode } from '../lib/blocks/render'; 31 32function mentionPara( handle: string, did: string ): BlockNode { 33 return { 34 name: 'core/paragraph', 35 attributes: { 36 content: `Hi <a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`, 37 }, 38 innerBlocks: [], 39 }; 40} 41 42const EDITING = { 43 rkey: '3kdoc', 44 siteUri: 'at://did:plc:me/site.standard.publication/pub1', 45 siteSlug: 'my-blog', 46 publishedAt: '2026-06-08T00:00:00.000Z', 47}; 48 49beforeEach( () => { 50 updateDocument.mockClear(); 51 publish.mockClear(); 52} ); 53 54async function clickUpdate( 55 description: string, 56 coverImage?: unknown, 57 onComplete?: ( r: { articleUrl: string; isEditing: boolean } ) => void 58) { 59 const container = document.createElement( 'div' ); 60 document.body.appendChild( container ); 61 const root = createRoot( container ); 62 await act( async () => { 63 root.render( 64 createElement( PublishPanel, { 65 agent: {} as Agent, 66 identity: { did: 'did:plc:me', handle: 'me.test' }, 67 blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 68 blobRegistry: new Map(), 69 publications: [], 70 editing: EDITING, 71 title: 'A title', 72 description, 73 coverImage: coverImage as never, 74 onComplete, 75 } ) 76 ); 77 } ); 78 const button = Array.from( container.querySelectorAll( 'button' ) ).find( 79 ( b ) => b.textContent === 'Update' 80 )!; 81 await act( async () => { 82 button.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 83 } ); 84 // The submit handler is async, so a state update can still be settling at 85 // teardown; unmount inside act() so it isn't flagged "not wrapped in act". 86 await act( async () => { 87 root.unmount(); 88 } ); 89 container.remove(); 90} 91 92const PUB = { 93 uri: 'at://did:plc:me/site.standard.publication/pub1', 94 cid: 'bafypub', 95 rkey: 'pub1', 96 slug: 'my-blog', 97 name: 'My Blog', 98}; 99 100async function clickPublish( 101 onComplete: ( r: { articleUrl: string; isEditing: boolean } ) => void 102) { 103 const container = document.createElement( 'div' ); 104 document.body.appendChild( container ); 105 const root = createRoot( container ); 106 await act( async () => { 107 root.render( 108 createElement( PublishPanel, { 109 agent: {} as Agent, 110 identity: { did: 'did:plc:me', handle: 'me.test' }, 111 blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 112 blobRegistry: new Map(), 113 publications: [ PUB ] as never, 114 title: 'A title', 115 description: 'A lede', 116 onComplete, 117 } ) 118 ); 119 } ); 120 const find = ( label: string ) => 121 Array.from( container.querySelectorAll( 'button' ) ).find( 122 ( b ) => b.textContent === label 123 )!; 124 await act( async () => { 125 find( 'Publish…' ).dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); 126 } ); 127 await act( async () => { 128 find( 'Publish & post to Bluesky' ).dispatchEvent( 129 new MouseEvent( 'click', { bubbles: true } ) 130 ); 131 } ); 132 // The publish handler is async, so a state update can still be settling at 133 // teardown; unmount inside act() so it isn't flagged "not wrapped in act". 134 await act( async () => { 135 root.unmount(); 136 } ); 137 container.remove(); 138} 139 140/** 141 * Render a PublishPanel and hand back its container for inspection (no interaction). 142 * Mirrors the prop shapes the click harnesses above build: a new-article panel passes 143 * `publications: [ PUB ]` and no `editing`; an editing panel passes `editing: EDITING`. 144 */ 145async function renderPanel( props: { 146 editing?: typeof EDITING; 147 publications: unknown[]; 148 description: string; 149} ): Promise< { container: HTMLDivElement; cleanup: () => void } > { 150 const container = document.createElement( 'div' ); 151 document.body.appendChild( container ); 152 const root = createRoot( container ); 153 await act( async () => { 154 root.render( 155 createElement( PublishPanel, { 156 agent: {} as Agent, 157 identity: { did: 'did:plc:me', handle: 'me.test' }, 158 blocks: [ { name: 'core/paragraph', attributes: { content: 'Body' }, innerBlocks: [] } ] as never, 159 blobRegistry: new Map(), 160 publications: props.publications as never, 161 editing: props.editing, 162 title: 'A title', 163 description: props.description, 164 } ) 165 ); 166 } ); 167 const cleanup = () => { 168 act( () => { 169 root.unmount(); 170 } ); 171 container.remove(); 172 }; 173 return { container, cleanup }; 174} 175 176describe( 'PublishPanel post-length gate', () => { 177 it( 'disables Publish and shows the over-limit counter on a too-long new article', async () => { 178 const { container, cleanup } = await renderPanel( { 179 publications: [ PUB ], 180 description: 'x'.repeat( 320 ), 181 } ); 182 const button = Array.from( container.querySelectorAll( 'button' ) ).find( 183 ( b ) => b.textContent === 'Publish…' 184 )!; 185 expect( button.disabled ).toBe( true ); 186 expect( container.textContent ).toContain( 'Bluesky post:' ); 187 expect( container.textContent ).toContain( 'too long to publish' ); 188 cleanup(); 189 } ); 190 191 it( 'shows no counter for an in-limit new article', async () => { 192 const { container, cleanup } = await renderPanel( { 193 publications: [ PUB ], 194 description: 'A short subtitle', 195 } ); 196 const button = Array.from( container.querySelectorAll( 'button' ) ).find( 197 ( b ) => b.textContent === 'Publish…' 198 )!; 199 expect( button.disabled ).toBe( false ); 200 // Under the limit the counter stays out of the way entirely. 201 expect( container.textContent ).not.toContain( 'Bluesky post:' ); 202 cleanup(); 203 } ); 204 205 it( 'does not disable Update or show a counter when editing the same long content', async () => { 206 const { container, cleanup } = await renderPanel( { 207 editing: EDITING, 208 publications: [], 209 description: 'x'.repeat( 320 ), 210 } ); 211 const button = Array.from( container.querySelectorAll( 'button' ) ).find( 212 ( b ) => b.textContent === 'Update' 213 )!; 214 expect( button.disabled ).toBe( false ); 215 expect( container.textContent ).not.toContain( 'Bluesky post:' ); 216 cleanup(); 217 } ); 218} ); 219 220describe( 'computePostPreview', () => { 221 it( 'reports the mentioned handles and a grapheme count', () => { 222 const preview = computePostPreview( { 223 title: 'Hello', 224 lede: 'A lede', 225 blocks: [ mentionPara( 'alice.bsky.social', 'did:plc:alice' ) ], 226 handle: 'me.bsky.social', 227 slug: 'pub', 228 } ); 229 expect( preview.handles ).toEqual( [ '@alice.bsky.social' ] ); 230 expect( preview.graphemes ).toBeGreaterThan( 0 ); 231 expect( preview.overLimit ).toBe( false ); 232 } ); 233 234 it( 'flags overLimit when the assembled post exceeds 300 graphemes', () => { 235 const preview = computePostPreview( { 236 title: 'Hello', 237 lede: 'x'.repeat( 320 ), 238 blocks: [], 239 handle: 'me.bsky.social', 240 slug: 'pub', 241 } ); 242 expect( preview.overLimit ).toBe( true ); 243 } ); 244} ); 245 246describe( 'PublishPanel', () => { 247 it( 'forwards the lede to updateDocument as description', async () => { 248 await clickUpdate( 'My hand-written lede' ); 249 expect( updateDocument ).toHaveBeenCalledTimes( 1 ); 250 expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 251 description: 'My hand-written lede', 252 } ); 253 } ); 254 255 it( 'forwards the coverImage to updateDocument', async () => { 256 const cover = { 257 $type: 'blob', 258 ref: { $link: 'bafycover' }, 259 mimeType: 'image/png', 260 size: 9000, 261 }; 262 await clickUpdate( 'A lede', cover ); 263 expect( updateDocument.mock.calls[ 0 ][ 2 ] ).toMatchObject( { 264 coverImage: cover, 265 } ); 266 } ); 267 268 it( 'reports the article URL and isEditing=true on update', async () => { 269 const onComplete = vi.fn(); 270 await clickUpdate( 'A lede', undefined, onComplete ); 271 expect( onComplete ).toHaveBeenCalledWith( { 272 articleUrl: 'https://x', 273 isEditing: true, 274 } ); 275 } ); 276 277 it( 'reports the article URL and isEditing=false on a new publish', async () => { 278 const onComplete = vi.fn(); 279 await clickPublish( onComplete ); 280 expect( onComplete ).toHaveBeenCalledWith( { 281 articleUrl: 'https://x', 282 isEditing: false, 283 } ); 284 } ); 285} );