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