A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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} );