@@ -20,6 +20,7 @@ afterEach(() => {
2020 document . body . innerHTML = '' ;
2121 vi . unstubAllGlobals ( ) ;
2222 vi . useRealTimers ( ) ;
23+ delete ( document as unknown as { execCommand ?: unknown } ) . execCommand ;
2324} ) ;
2425
2526describe ( 'ody-accordion' , ( ) => {
@@ -109,46 +110,87 @@ describe('ody-tabs', () => {
109110} ) ;
110111
111112describe ( 'ody-copy-button' , ( ) => {
112- it ( 'copies the value, shows success, dispatches copy, then reverts' , async ( ) => {
113+ /** Stub the synchronous clipboard path (happy-dom has no execCommand). */
114+ function stubExecCommand ( result : boolean ) : ReturnType < typeof vi . fn > {
115+ const execCommand = vi . fn ( ) . mockReturnValue ( result ) ;
116+ ( document as unknown as { execCommand : unknown } ) . execCommand = execCommand ;
117+ return execCommand ;
118+ }
119+
120+ it ( 'copies the value synchronously, shows success, dispatches copy, then reverts' , async ( ) => {
113121 vi . useFakeTimers ( ) ;
122+ const execCommand = stubExecCommand ( true ) ;
114123 const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
115124 vi . stubGlobal ( 'navigator' , { clipboard : { writeText } } ) ;
116125 const el = await mount < HTMLElement > ( '<ody-copy-button value="hello" label="Copy" success-duration="500"></ody-copy-button>' ) ;
117126 const detail : Array < { value : string ; ok : boolean } > = [ ] ;
118127 el . addEventListener ( 'copy' , ( e ) => detail . push ( ( e as CustomEvent ) . detail ) ) ;
119128 el . querySelector ( 'button' ) ! . click ( ) ;
120- await Promise . resolve ( ) ;
121- await Promise . resolve ( ) ;
122- expect ( writeText ) . toHaveBeenCalledWith ( 'hello' ) ;
129+ expect ( execCommand ) . toHaveBeenCalledWith ( 'copy' ) ;
123130 expect ( el . querySelector ( '.ody-button.success' ) ) . not . toBeNull ( ) ;
124131 expect ( detail ) . toEqual ( [ { value : 'hello' , ok : true } ] ) ;
132+ // The sync path succeeded, so the async API must not be touched.
133+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
125134 vi . advanceTimersByTime ( 500 ) ;
126135 expect ( el . querySelector ( '.ody-button.success' ) ) . toBeNull ( ) ;
127136 } ) ;
128137
129- it ( 'shows an error state when the clipboard write rejects' , async ( ) => {
130- const writeText = vi . fn ( ) . mockRejectedValue ( new Error ( 'nope' ) ) ;
138+ it . each ( [
139+ [ 'resolves' , ( ) => vi . fn ( ) . mockResolvedValue ( undefined ) ] ,
140+ [ 'rejects' , ( ) => vi . fn ( ) . mockRejectedValue ( new Error ( 'blocked' ) ) ] ,
141+ ] ) ( 'falls back to a best-effort async write when the sync copy fails and the async %s, reflecting the sync result immediately' , async ( _label , makeWriteText ) => {
142+ stubExecCommand ( false ) ;
143+ const writeText = makeWriteText ( ) ;
131144 vi . stubGlobal ( 'navigator' , { clipboard : { writeText } } ) ;
132145 const el = await mount < HTMLElement > ( '<ody-copy-button value="x"></ody-copy-button>' ) ;
133146 const detail : Array < { ok : boolean } > = [ ] ;
134147 el . addEventListener ( 'copy' , ( e ) => detail . push ( ( e as CustomEvent ) . detail ) ) ;
135148 el . querySelector ( 'button' ) ! . click ( ) ;
136- await Promise . resolve ( ) ;
137- await Promise . resolve ( ) ;
149+ // UI reflects the (failed) sync result immediately, without waiting on the async API.
138150 expect ( el . querySelector ( '.ody-button.danger' ) ) . not . toBeNull ( ) ;
139151 expect ( el . querySelector ( '.ody-button--icon-only' ) ) . not . toBeNull ( ) ;
140152 expect ( detail [ 0 ] . ok ) . toBe ( false ) ;
153+ // Best-effort async path was still invoked.
154+ expect ( writeText ) . toHaveBeenCalledWith ( 'x' ) ;
155+ await Promise . resolve ( ) ;
156+ await Promise . resolve ( ) ;
157+ // The late async result (success or failure) never flips the UI back.
158+ expect ( el . querySelector ( '.ody-button.danger' ) ) . not . toBeNull ( ) ;
159+ expect ( detail ) . toHaveLength ( 1 ) ;
141160 } ) ;
142161
143- it ( 'falls back to an error state when the clipboard API is missing' , async ( ) => {
162+ it ( 'shows an error state when both the sync copy and the async API are unavailable' , async ( ) => {
163+ stubExecCommand ( false ) ;
144164 vi . stubGlobal ( 'navigator' , { } ) ;
145165 const el = await mount < HTMLElement > ( '<ody-copy-button value="x" label="C"></ody-copy-button>' ) ;
146166 el . querySelector ( 'button' ) ! . click ( ) ;
147167 expect ( el . querySelector ( '.ody-button.danger' ) ) . not . toBeNull ( ) ;
148168 } ) ;
149169
170+ it ( 'shows an error state for an empty value without running the sync copy' , async ( ) => {
171+ const execCommand = stubExecCommand ( true ) ;
172+ const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
173+ vi . stubGlobal ( 'navigator' , { clipboard : { writeText } } ) ;
174+ const el = await mount < HTMLElement > ( '<ody-copy-button label="C"></ody-copy-button>' ) ;
175+ el . querySelector ( 'button' ) ! . click ( ) ;
176+ expect ( el . querySelector ( '.ody-button.danger' ) ) . not . toBeNull ( ) ;
177+ // Empty value short-circuits the sync copy before execCommand runs.
178+ expect ( execCommand ) . not . toHaveBeenCalled ( ) ;
179+ } ) ;
180+
181+ it ( 'tolerates a throwing execCommand and reports an error' , async ( ) => {
182+ ( document as unknown as { execCommand : unknown } ) . execCommand = vi . fn ( ( ) => {
183+ throw new Error ( 'blocked' ) ;
184+ } ) ;
185+ vi . stubGlobal ( 'navigator' , { } ) ;
186+ const el = await mount < HTMLElement > ( '<ody-copy-button value="x"></ody-copy-button>' ) ;
187+ el . querySelector ( 'button' ) ! . click ( ) ;
188+ expect ( el . querySelector ( '.ody-button.danger' ) ) . not . toBeNull ( ) ;
189+ } ) ;
190+
150191 it ( 'clears a pending timer on a second copy and tolerates a non-numeric duration' , async ( ) => {
151- vi . stubGlobal ( 'navigator' , { clipboard : { } } ) ; // no writeText -> error path, runs #feedback
192+ stubExecCommand ( false ) ;
193+ vi . stubGlobal ( 'navigator' , { } ) ; // error path, runs #feedback
152194 const el = await mount < HTMLElement > ( '<ody-copy-button value="x" success-duration="abc"></ody-copy-button>' ) ;
153195 el . querySelector ( 'button' ) ! . click ( ) ;
154196 // second click while the first revert timer is still pending exercises clearTimeout
@@ -158,7 +200,8 @@ describe('ody-copy-button', () => {
158200
159201 it ( 'skips the revert render after the element is disconnected' , async ( ) => {
160202 vi . useFakeTimers ( ) ;
161- vi . stubGlobal ( 'navigator' , { clipboard : { } } ) ;
203+ stubExecCommand ( false ) ;
204+ vi . stubGlobal ( 'navigator' , { } ) ;
162205 const el = await mount < HTMLElement > ( '<ody-copy-button value="x" success-duration="100"></ody-copy-button>' ) ;
163206 el . querySelector ( 'button' ) ! . click ( ) ;
164207 el . remove ( ) ; // now not connected; the pending timer must not throw on render
0 commit comments