@@ -15,20 +15,28 @@ function assert(cond, message) {
1515}
1616
1717// --- minimal fake DOM -------------------------------------------------
18- // script.js only assigns elem.innerHTML across elements and to a fresh
19- // textarea, so innerHTML is modeled as an opaque snapshot (string or
20- // {ownText, children}) instead of parsed HTML.
18+ // script.js only assigns elem.innerHTML across elements, so innerHTML is
19+ // modeled as an opaque snapshot (string or {ownText, children}) instead of
20+ // parsed HTML.
2121class FakeElement {
2222 constructor ( tag , className = '' , text = '' ) {
2323 this . tagName = tag . toUpperCase ( )
2424 this . className = className
2525 this . ownText = text
2626 this . children = [ ]
2727 this . onclick = null
28+ this . value = ''
29+ const classes = new Set ( )
30+ this . classList = {
31+ add ( c ) { classes . add ( c ) } ,
32+ remove ( c ) { classes . delete ( c ) } ,
33+ contains ( c ) { return classes . has ( c ) } ,
34+ }
2835 }
2936 setAttribute ( name , value ) {
3037 if ( name === 'class' ) this . className = value
3138 }
39+ select ( ) { }
3240 appendChild ( child ) {
3341 this . children . push ( child )
3442 return child
@@ -84,6 +92,7 @@ class FakeElement {
8492function makeDocument ( elements ) {
8593 return {
8694 _elements : elements ,
95+ body : new FakeElement ( 'body' ) ,
8796 createElement ( tag ) { return new FakeElement ( tag ) } ,
8897 getElementsByClassName ( name ) {
8998 return this . _elements . filter ( e => e . className . split ( ' ' ) . indexOf ( name ) >= 0 )
@@ -99,44 +108,72 @@ function makeDocument(elements) {
99108const here = import . meta. url . replace ( / ^ f i l e : \/ \/ / , '' ) . replace ( / \/ [ ^ / ] * $ / , '' )
100109const source = std . loadFile ( here + '/../../theme/default/script.js' )
101110
102- function runOnload ( elements ) {
111+ function runOnload ( elements , navigatorFake ) {
103112 globalThis . document = makeDocument ( elements )
104113 globalThis . window = { setTimeout ( ) { return 0 } }
114+ globalThis . navigator = navigatorFake || { }
105115 ; ( 0 , eval ) ( source )
106116 globalThis . window . onload ( )
107117 return elements
108118}
109119
120+ // Clipboard API を捕捉する navigator フェイク
121+ function clipboardSpy ( ) {
122+ const written = [ ]
123+ return {
124+ written,
125+ navigator : {
126+ clipboard : {
127+ writeText ( text ) {
128+ written . push ( text )
129+ return Promise . resolve ( )
130+ } ,
131+ } ,
132+ } ,
133+ }
134+ }
135+
136+ // クリックの Promise 連鎖(writeClipboard().then)を流すための待ち
137+ async function settle ( ) {
138+ await 0
139+ await 0
140+ }
141+
110142// A ruby sample keeps getting the COPY button (regression)
111143{
144+ const spy = clipboardSpy ( )
112145 const pre = new FakeElement ( 'pre' , 'highlight ruby' )
113146 pre . appendChild ( new FakeElement ( 'code' , '' , 'puts 1\n' ) )
114- runOnload ( [ pre ] )
115- assert ( pre . childByClass ( 'highlight__copy-button' ) !== null ,
116- 'pre.highlight.ruby gets a COPY button' )
117- assert ( pre . firstChild === pre . childByClass ( 'highlight__copy-button' ) ,
118- 'COPY button is prepended as the first child' )
119- const copyText = pre . childByClass ( 'highlight__copy-text' )
120- assert ( copyText !== null && copyText . textContent === 'puts 1\n' ,
121- 'copy text preserves the sample code' )
147+ runOnload ( [ pre ] , spy . navigator )
148+ const btn = pre . childByClass ( 'highlight__copy-button' )
149+ assert ( btn !== null , 'pre.highlight.ruby gets a COPY button' )
150+ assert ( pre . firstChild === btn , 'COPY button is prepended as the first child' )
151+ btn . onclick ( )
152+ await settle ( )
153+ assert ( spy . written . length === 1 && spy . written [ 0 ] === 'puts 1\n' ,
154+ 'clicking COPY writes the sample code via the Clipboard API' )
155+ assert ( btn . classList . contains ( 'copied' ) ,
156+ 'the copied indicator is shown after a successful write' )
122157}
123158
124159// A plain <pre> (no language fence, //emlist{ origin) also gets the button
125160{
161+ const spy = clipboardSpy ( )
126162 const pre = new FakeElement ( 'pre' , '' , 'ary = []\n' )
127- runOnload ( [ pre ] )
128- assert ( pre . childByClass ( 'highlight__copy-button' ) !== null ,
129- 'plain <pre> without highlight class gets a COPY button' )
130- const copyText = pre . childByClass ( 'highlight__copy-text' )
131- assert ( copyText !== null && copyText . textContent === 'ary = []\n' ,
132- 'plain <pre> copy text preserves the block content' )
163+ runOnload ( [ pre ] , spy . navigator )
164+ const btn = pre . childByClass ( 'highlight__copy-button' )
165+ assert ( btn !== null , 'plain <pre> without highlight class gets a COPY button' )
166+ btn . onclick ( )
167+ await settle ( )
168+ assert ( spy . written [ 0 ] === 'ary = []\n' ,
169+ 'plain <pre> copy writes the block content' )
133170}
134171
135172// A non-ruby language fence keeps the button
136173{
137174 const pre = new FakeElement ( 'pre' , 'highlight c' )
138175 pre . appendChild ( new FakeElement ( 'code' , '' , 'VALUE v;\n' ) )
139- runOnload ( [ pre ] )
176+ runOnload ( [ pre ] , clipboardSpy ( ) . navigator )
140177 assert ( pre . childByClass ( 'highlight__copy-button' ) !== null ,
141178 'pre.highlight.c gets a COPY button' )
142179}
@@ -145,31 +182,63 @@ function runOnload(elements) {
145182{
146183 const div = new FakeElement ( 'div' , 'highlight' )
147184 const pre = new FakeElement ( 'pre' , '' )
148- runOnload ( [ div , pre ] )
185+ runOnload ( [ div , pre ] , clipboardSpy ( ) . navigator )
149186 assert ( div . childByClass ( 'highlight__copy-button' ) === null ,
150187 'non-pre element does not get a COPY button' )
151188}
152189
153190// The caption is excluded from the copied text
154191{
192+ const spy = clipboardSpy ( )
155193 const pre = new FakeElement ( 'pre' , 'highlight ruby' )
156194 pre . appendChild ( new FakeElement ( 'span' , 'caption' , '例' ) )
157195 pre . appendChild ( new FakeElement ( 'code' , '' , 'p 42\n' ) )
158- runOnload ( [ pre ] )
159- const copyText = pre . childByClass ( 'highlight__copy-text' )
160- assert ( copyText !== null && copyText . textContent === 'p 42\n' ,
161- 'caption text is excluded from the copy text' )
196+ runOnload ( [ pre ] , spy . navigator )
197+ pre . childByClass ( 'highlight__copy-button' ) . onclick ( )
198+ await settle ( )
199+ assert ( spy . written [ 0 ] === 'p 42\n' ,
200+ 'caption text is excluded from the copied text' )
162201 assert ( pre . childByClass ( 'caption' ) !== null ,
163202 'caption itself stays visible in the sample' )
164203}
165204
166205// Leading blank lines are stripped, trailing ones squeezed to one
167206{
207+ const spy = clipboardSpy ( )
168208 const pre = new FakeElement ( 'pre' , '' , '\n\nputs 1\n\n\n' )
169- runOnload ( [ pre ] )
170- const copyText = pre . childByClass ( 'highlight__copy-text' )
171- assert ( copyText !== null && copyText . textContent === 'puts 1\n' ,
172- 'copy text is trimmed (leading newlines dropped, trailing squeezed)' )
209+ runOnload ( [ pre ] , spy . navigator )
210+ pre . childByClass ( 'highlight__copy-button' ) . onclick ( )
211+ await settle ( )
212+ assert ( spy . written [ 0 ] === 'puts 1\n' ,
213+ 'copied text is trimmed (leading newlines dropped, trailing squeezed)' )
214+ }
215+
216+ // Clipboard API が無い環境では textarea + execCommand にフォールバックする
217+ {
218+ const pre = new FakeElement ( 'pre' , '' , 'fallback code\n' )
219+ const elements = [ pre ]
220+ globalThis . document = makeDocument ( elements )
221+ let captured = null
222+ globalThis . document . execCommand = function ( command ) {
223+ if ( command === 'copy' ) {
224+ const ta = globalThis . document . body . children . find ( c => c . tagName === 'TEXTAREA' )
225+ captured = ta ? ta . value : null
226+ }
227+ return true
228+ }
229+ globalThis . window = { setTimeout ( ) { return 0 } }
230+ globalThis . navigator = { } // clipboard なし
231+ ; ( 0 , eval ) ( source )
232+ globalThis . window . onload ( )
233+ const btn = pre . childByClass ( 'highlight__copy-button' )
234+ btn . onclick ( )
235+ await settle ( )
236+ assert ( captured === 'fallback code\n' ,
237+ 'without the Clipboard API the text is copied via textarea + execCommand' )
238+ assert ( globalThis . document . body . children . length === 0 ,
239+ 'the fallback textarea is removed after copying' )
240+ assert ( btn . classList . contains ( 'copied' ) ,
241+ 'the copied indicator is shown for the fallback path too' )
173242}
174243
175244if ( failures > 0 ) {
0 commit comments