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 5.9 kB View raw
1import { describe, it, expect } from 'vitest'; 2import { act, createElement } from 'react'; 3import { renderToStaticMarkup } from 'react-dom/server'; 4import { createRoot } from 'react-dom/client'; 5import type { Agent } from '@atproto/api'; 6 7// react-dom/client + act need this flag so React treats vitest's jsdom as a test environment. 8( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 9import PublicationForm from './PublicationForm'; 10import { THEME_PRESETS } from '../lib/publish/themes'; 11import type { Publication } from '../lib/publish/publications'; 12 13const baseProps = { 14 agent: {} as Agent, 15 did: 'did:plc:alice', 16 pdsUrl: null, 17 handle: 'alice.test', 18 onSaved: () => {}, 19 onCancel: () => {}, 20}; 21 22function renderForm( existing?: Publication ): string { 23 return renderToStaticMarkup( createElement( PublicationForm, { ...baseProps, existing } ) ); 24} 25 26/** Every `<input type="radio">` tag in the markup. */ 27function radios( markup: string ): string[] { 28 return markup.match( /<input[^>]*type="radio"[^>]*>/g ) ?? []; 29} 30 31/** The `value=""` of the single checked radio, or null. */ 32function checkedValue( markup: string ): string | null { 33 const checked = radios( markup ).find( ( tag ) => /\schecked\b/.test( tag ) ); 34 if ( ! checked ) { 35 return null; 36 } 37 return checked.match( /value="([^"]*)"/ )?.[ 1 ] ?? null; 38} 39 40describe( 'PublicationForm theme picker', () => { 41 it( 'renders a "no theme" option plus every preset as radios in one group', () => { 42 const markup = renderForm(); 43 expect( markup ).toContain( 'role="radiogroup"' ); 44 expect( markup ).toContain( 'aria-label="Theme"' ); 45 expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 46 for ( const preset of THEME_PRESETS ) { 47 expect( markup ).toContain( `value="${ preset.slug }"` ); 48 expect( markup ).toContain( preset.label ); 49 } 50 } ); 51 52 it( 'defaults a new publication to "no theme" (empty value checked)', () => { 53 expect( checkedValue( renderForm() ) ).toBe( '' ); 54 } ); 55 56 it( 'pre-selects the matching preset when editing a themed publication', () => { 57 const existing: Publication = { 58 uri: 'at://x', 59 cid: 'bafyx', 60 rkey: 'r', 61 slug: 'blog', 62 name: 'Blog', 63 basicTheme: THEME_PRESETS[ 2 ].colors, // dusk 64 }; 65 expect( checkedValue( renderForm( existing ) ) ).toBe( 'dusk' ); 66 } ); 67 68 it( 'surfaces and pre-selects a "Current" option when the stored theme matches no preset', () => { 69 // A valid theme whose colours match no preset (e.g. a preset whose values later changed). 70 // It must remain visible + selected so an unrelated edit doesn't silently erase it. 71 const existing: Publication = { 72 uri: 'at://x', 73 cid: 'bafyx', 74 rkey: 'r', 75 slug: 'blog', 76 name: 'Blog', 77 basicTheme: { 78 $type: 'site.standard.theme.basic', 79 background: { r: 10, g: 20, b: 30 }, 80 foreground: { r: 240, g: 240, b: 240 }, 81 accent: { r: 100, g: 50, b: 200 }, 82 accentForeground: { r: 255, g: 255, b: 255 }, 83 }, 84 }; 85 const markup = renderForm( existing ); 86 expect( markup ).toContain( 'Current' ); 87 expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 2 ); // none + custom + presets 88 expect( checkedValue( markup ) ).toBe( 'custom' ); 89 } ); 90 91 it( 'shows no "Current" option for an unthemed publication', () => { 92 const existing: Publication = { uri: 'at://x', cid: 'bafyx', rkey: 'r', slug: 'blog', name: 'Blog' }; 93 const markup = renderForm( existing ); 94 expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 95 expect( checkedValue( markup ) ).toBe( '' ); 96 } ); 97} ); 98 99describe( 'PublicationForm save lifecycle', () => { 100 it( 'clears the "Saving…" state after a successful save even when the form stays mounted', async () => { 101 // The Settings tab keeps the SAME PublicationForm instance mounted across a save: 102 // onSaved just re-renders the manager on the same tab, so the form never unmounts. 103 // onSubmit must therefore reset `saving` on the success path — relying on unmount left 104 // the button stuck on "Saving…" forever despite the PDS returning 200 (the reported bug). 105 const existing: Publication = { 106 uri: 'at://did:plc:alice/site.standard.publication/abc', 107 cid: 'bafypub', 108 rkey: 'abc', 109 slug: 'blog', 110 name: 'Blog', 111 }; 112 let resolvePut: ( ( value: unknown ) => void ) | null = null; 113 const agent = { 114 com: { 115 atproto: { 116 repo: { 117 putRecord: () => 118 new Promise( ( resolve ) => { 119 resolvePut = resolve; 120 } ), 121 }, 122 }, 123 }, 124 } as unknown as Agent; 125 126 const container = document.createElement( 'div' ); 127 document.body.appendChild( container ); 128 const root = createRoot( container ); 129 await act( async () => { 130 root.render( 131 createElement( PublicationForm, { ...baseProps, agent, existing, onSaved: () => {} } ) 132 ); 133 } ); 134 135 const form = container.querySelector( 'form' ) as HTMLFormElement; 136 const button = container.querySelector( '.pubform__save' ) as HTMLButtonElement; 137 expect( button.textContent ).toBe( 'Save changes' ); 138 139 await act( async () => { 140 form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ); 141 } ); 142 expect( button.textContent ).toBe( 'Saving…' ); 143 expect( button.disabled ).toBe( true ); 144 145 // The PDS write resolves (the 200 the user saw). The button must return to normal. 146 // Flush the whole async chain (putRecord resolve → updatePublication resolve → onSubmit 147 // continuation → setSaving) inside act. A macrotask drains every queued microtask first, 148 // so the setSaving update lands inside the act() scope rather than escaping it. 149 await act( async () => { 150 resolvePut?.( { data: {} } ); 151 await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); 152 } ); 153 expect( button.textContent ).toBe( 'Save changes' ); 154 expect( button.disabled ).toBe( false ); 155 156 await act( async () => { 157 root.unmount(); 158 } ); 159 container.remove(); 160 } ); 161} );