Skip to content

Commit 7c077fc

Browse files
authored
Merge pull request #240 from znz/run-web-worker
実行可能サンプル(RUN)を Web Worker 化して停止・タイムアウトに対応
2 parents e3807d5 + ea2753c commit 7c077fc

10 files changed

Lines changed: 720 additions & 136 deletions

File tree

lib/bitclust/subcommands/statichtml_command.rb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,18 +327,26 @@ def create_search_index(outputdir, db, fdb)
327327
:verbose => @verbose, :preserve => true)
328328
end
329329

330-
# Copy the RUN-button script (statichtml --run-ruby-wasm). A themedir
331-
# without js/run.js is tolerated: warn and skip instead of aborting the
332-
# whole build.
330+
# Scripts for the RUN-button feature (statichtml --run-ruby-wasm):
331+
# run.js sets up the button and compiles the wasm module on the main
332+
# thread; run-worker.js is the module Worker it spawns per execution
333+
# (see theme/default/js/run.js for why: STOP/timeout both just
334+
# Worker#terminate() it). Both are required for the feature to work.
335+
RUN_RUBY_WASM_JS_FILES = %w[run.js run-worker.js].freeze
336+
337+
# Copy the RUN-button scripts. A themedir missing one of them is
338+
# tolerated: warn and skip instead of aborting the whole build.
333339
def copy_run_ruby_wasm_script
334-
run_js = @manager_config[:themedir] + "js" + "run.js"
335-
unless run_js.file?
336-
$stderr.puts "warning: #{run_js} not found; RUN button script not copied"
337-
return
338-
end
339340
jsdir = @outputdir + "js"
340-
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
341-
FileUtils.cp(run_js.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
341+
RUN_RUBY_WASM_JS_FILES.each do |name|
342+
src = @manager_config[:themedir] + "js" + name
343+
unless src.file?
344+
$stderr.puts "warning: #{src} not found; RUN button script not copied"
345+
next
346+
end
347+
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
348+
FileUtils.cp(src.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
349+
end
342350
end
343351

344352
def create_html_file(entry, manager, outputdir, db)

sig/bitclust/subcommands/statichtml_command.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ module BitClust
121121

122122
def create_index_html: (Pathname outputdir) -> void
123123

124+
RUN_RUBY_WASM_JS_FILES: Array[String]
125+
124126
def copy_run_ruby_wasm_script: () -> void
125127

126128
SEARCH_JS_FILES: Array[String]

test/js/test_run.mjs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
// QuickJS-based tests for the pure logic in theme/default/js/run.js.
1+
// QuickJS-based tests for the pure logic in theme/default/js/run.js and
2+
// theme/default/js/run-worker.js.
23
// Run with: qjs test/js/test_run.mjs (see the "test:js" rake task)
3-
// Importing the module doubles as a syntax/eval smoke test: its DOM setup is
4-
// guarded by `typeof window !== 'undefined'`.
5-
import { createOnceLoader, PRELUDE, formatRunError, truncateOutput } from '../../theme/default/js/run.js'
4+
// Importing each module doubles as a syntax/eval smoke test: run.js's DOM
5+
// setup is guarded by `typeof window !== 'undefined'`, and run-worker.js's
6+
// Worker setup is guarded by `typeof self !== 'undefined' && typeof
7+
// WorkerGlobalScope !== 'undefined'` -- neither exists under QuickJS, so
8+
// importing either file here never touches a real DOM/Worker.
9+
import {
10+
createOnceLoader, formatRunError, truncateOutput,
11+
accumulateOutput, STOPPED_NOTE, DONE_NOTE, timeoutNote, editableText,
12+
} from '../../theme/default/js/run.js'
13+
import { PRELUDE, formatRunError as formatWorkerRunError } from '../../theme/default/js/run-worker.js'
614

715
let failures = 0
816
function assert(cond, message) {
@@ -52,11 +60,20 @@ function assert(cond, message) {
5260
'next call after a rejection retries the load')
5361
}
5462

55-
// PRELUDE captures both streams into one StringIO
56-
assert(PRELUDE.includes('require "stringio"') &&
57-
PRELUDE.includes('$stdout = StringIO.new') &&
63+
// PRELUDE (run-worker.js) streams both channels to the main thread via the
64+
// JS bridge, instead of buffering into a StringIO like the old single-eval
65+
// PRELUDE did.
66+
assert(PRELUDE.includes('require "js"') &&
67+
PRELUDE.includes('JS.global.call(:postOutput, text)') &&
68+
PRELUDE.includes('$stdout = JSStreamIO.new') &&
5869
PRELUDE.includes('$stderr = $stdout'),
59-
'PRELUDE redirects $stdout and $stderr into one StringIO')
70+
'PRELUDE redirects $stdout and $stderr through postOutput()')
71+
72+
// run-worker.js's formatRunError is the same shape as run.js's copy (each
73+
// runs in its own realm -- main thread vs. worker -- so it is a small
74+
// intentional duplication rather than an import cycle across the two files)
75+
assert(formatWorkerRunError(new Error('boom')) === 'boom',
76+
'run-worker.js formatRunError matches run.js formatRunError')
6077

6178
// formatRunError
6279
assert(formatRunError(new Error('boom')) === 'boom',
@@ -78,6 +95,43 @@ assert(truncateOutput('short') === 'short', 'truncateOutput keeps short output')
7895
'truncateOutput cuts long output with a marker')
7996
}
8097

98+
// accumulateOutput: incremental version of truncateOutput, used to append
99+
// each Worker 'output' message to what is already on screen.
100+
assert(accumulateOutput('foo', 'bar') === 'foobar',
101+
'accumulateOutput appends a chunk to the existing text')
102+
assert(accumulateOutput('', 'first') === 'first',
103+
'accumulateOutput handles an empty starting value')
104+
{
105+
const grown = accumulateOutput('x'.repeat(8), 'y'.repeat(8), 10)
106+
assert(grown === 'x'.repeat(8) + 'y'.repeat(2) + '\n... (truncated)',
107+
'accumulateOutput truncates once the cap is crossed mid-chunk')
108+
}
109+
{
110+
// A chunk arriving after the cap was already hit must be a no-op, not a
111+
// second "... (truncated)" marker or renewed growth.
112+
const alreadyTruncated = truncateOutput('x'.repeat(20), 10)
113+
const next = accumulateOutput(alreadyTruncated, 'more output', 10)
114+
assert(next === alreadyTruncated,
115+
'accumulateOutput drops further chunks once truncated')
116+
}
117+
118+
// STOPPED_NOTE / DONE_NOTE / timeoutNote: the notes appended to the output
119+
// on STOP / normal completion / timeout
120+
assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice')
121+
assert(DONE_NOTE === '(正常終了しました)',
122+
'DONE_NOTE marks a normal completion (visible even with empty output)')
123+
assert(timeoutNote(30) === '(30秒でタイムアウトしました)',
124+
'timeoutNote formats the configured timeout in seconds')
125+
126+
// editableText: flattening for edit drops the compiler's leading newline
127+
// (the artifact after <code>) but keeps interior/trailing newlines intact
128+
assert(editableText('\np 1\np 2\n') === 'p 1\np 2\n',
129+
'editableText strips the leading newline artifact')
130+
assert(editableText('\n\n\np 1\n') === 'p 1\n',
131+
'editableText strips all leading blank lines')
132+
assert(editableText('p 1\n\np 2\n') === 'p 1\n\np 2\n',
133+
'editableText keeps interior and trailing newlines')
134+
81135
if (failures > 0) {
82136
throw new Error(failures + ' JS test(s) failed')
83137
}

test/js/test_script.mjs

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,38 @@ class FakeElement {
3737
if (name === 'class') this.className = value
3838
}
3939
select() {}
40+
// 実 DOM 同様、挿入時は元の親から外して付け替える(parentNode も追跡)
4041
appendChild(child) {
42+
if (child.parentNode) child.parentNode.removeChild(child)
43+
child.parentNode = this
4144
this.children.push(child)
4245
return child
4346
}
4447
insertBefore(child, ref) {
48+
if (child.parentNode) child.parentNode.removeChild(child)
49+
child.parentNode = this
4550
const i = this.children.indexOf(ref)
4651
if (ref == null || i < 0) this.children.push(child)
4752
else this.children.splice(i, 0, child)
4853
return child
4954
}
5055
removeChild(child) {
5156
const i = this.children.indexOf(child)
52-
if (i >= 0) this.children.splice(i, 1)
57+
if (i >= 0) {
58+
this.children.splice(i, 1)
59+
child.parentNode = null
60+
}
5361
return child
5462
}
5563
get firstChild() {
5664
return this.children.length > 0 ? this.children[0] : null
5765
}
66+
get previousElementSibling() {
67+
if (!this.parentNode) return null
68+
const siblings = this.parentNode.children
69+
const i = siblings.indexOf(this)
70+
return i > 0 ? siblings[i - 1] : null
71+
}
5872
get textContent() {
5973
return this.ownText + this.children.map(c => c.textContent).join('')
6074
}
@@ -108,13 +122,17 @@ function makeDocument(elements) {
108122
const here = import.meta.url.replace(/^file:\/\//, '').replace(/\/[^/]*$/, '')
109123
const source = std.loadFile(here + '/../../theme/default/script.js')
110124

125+
// script.js は pre の直前(親の子リスト)にツールバーを差し込むので、
126+
// テスト対象の要素は root(body 相当)にぶら下げて親を持たせる
111127
function runOnload(elements, navigatorFake) {
128+
const root = new FakeElement('div')
129+
elements.forEach((e) => root.appendChild(e))
112130
globalThis.document = makeDocument(elements)
113131
globalThis.window = { setTimeout() { return 0 } }
114132
globalThis.navigator = navigatorFake || {}
115133
;(0, eval)(source)
116134
globalThis.window.onload()
117-
return elements
135+
return root
118136
}
119137

120138
// Clipboard API を捕捉する navigator フェイク
@@ -139,15 +157,35 @@ async function settle() {
139157
await 0
140158
}
141159

160+
// pre の直前のツールバー行 → ボタン置き場 → COPY を辿るヘルパー
161+
function toolbarOf(elem) {
162+
const prev = elem.previousElementSibling
163+
if (prev && prev.className.split(' ').indexOf('highlight__toolbar') >= 0) return prev
164+
return null
165+
}
166+
function buttonGroupOf(elem) {
167+
const toolbar = toolbarOf(elem)
168+
return toolbar && toolbar.childByClass('highlight__button-group')
169+
}
170+
function copyButtonOf(elem) {
171+
const group = buttonGroupOf(elem)
172+
return group && group.childByClass('highlight__copy-button')
173+
}
174+
142175
// A ruby sample keeps getting the COPY button (regression)
143176
{
144177
const spy = clipboardSpy()
145178
const pre = new FakeElement('pre', 'highlight ruby')
146179
pre.appendChild(new FakeElement('code', '', 'puts 1\n'))
147180
runOnload([pre], spy.navigator)
148-
const btn = pre.childByClass('highlight__copy-button')
181+
const btn = copyButtonOf(pre)
149182
assert(btn !== null, 'pre.highlight.ruby gets a COPY button')
150-
assert(pre.firstChild === btn, 'COPY button is prepended as the first child')
183+
assert(toolbarOf(pre) !== null,
184+
'a toolbar row is inserted just before the pre (outside of it)')
185+
assert(pre.childByClass('highlight__button-group') === null,
186+
'no button container is injected inside the pre itself')
187+
assert(buttonGroupOf(pre).firstChild === btn,
188+
'COPY button lives inside the toolbar button group')
151189
btn.onclick()
152190
await settle()
153191
assert(spy.written.length === 1 && spy.written[0] === 'puts 1\n',
@@ -161,7 +199,7 @@ async function settle() {
161199
const spy = clipboardSpy()
162200
const pre = new FakeElement('pre', '', 'ary = []\n')
163201
runOnload([pre], spy.navigator)
164-
const btn = pre.childByClass('highlight__copy-button')
202+
const btn = copyButtonOf(pre)
165203
assert(btn !== null, 'plain <pre> without highlight class gets a COPY button')
166204
btn.onclick()
167205
await settle()
@@ -174,7 +212,7 @@ async function settle() {
174212
const pre = new FakeElement('pre', 'highlight c')
175213
pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
176214
runOnload([pre], clipboardSpy().navigator)
177-
assert(pre.childByClass('highlight__copy-button') !== null,
215+
assert(copyButtonOf(pre) !== null,
178216
'pre.highlight.c gets a COPY button')
179217
}
180218

@@ -183,7 +221,7 @@ async function settle() {
183221
const div = new FakeElement('div', 'highlight')
184222
const pre = new FakeElement('pre', '')
185223
runOnload([div, pre], clipboardSpy().navigator)
186-
assert(div.childByClass('highlight__copy-button') === null,
224+
assert(copyButtonOf(div) === null,
187225
'non-pre element does not get a COPY button')
188226
}
189227

@@ -194,7 +232,7 @@ async function settle() {
194232
pre.appendChild(new FakeElement('span', 'caption', '例'))
195233
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
196234
runOnload([pre], spy.navigator)
197-
pre.childByClass('highlight__copy-button').onclick()
235+
copyButtonOf(pre).onclick()
198236
await settle()
199237
assert(spy.written[0] === 'p 42\n',
200238
'caption text is excluded from the copied text')
@@ -207,25 +245,48 @@ async function settle() {
207245
const spy = clipboardSpy()
208246
const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
209247
runOnload([pre], spy.navigator)
210-
pre.childByClass('highlight__copy-button').onclick()
248+
copyButtonOf(pre).onclick()
211249
await settle()
212250
assert(spy.written[0] === 'puts 1\n',
213251
'copied text is trimmed (leading newlines dropped, trailing squeezed)')
214252
}
215253

254+
// pre の直前に caption(タブ)があれば、ツールバーの左端に取り込まれる
255+
{
256+
const caption = new FakeElement('span', 'caption', '例')
257+
const pre = new FakeElement('pre', 'highlight ruby')
258+
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
259+
const root = runOnload([caption, pre], clipboardSpy().navigator)
260+
const toolbar = toolbarOf(pre)
261+
assert(toolbar !== null && toolbar.firstChild === caption,
262+
'a sibling caption is moved to the left edge of the toolbar')
263+
assert(root.children.indexOf(caption) < 0,
264+
'the caption is no longer a direct sibling of the pre')
265+
assert(toolbar.childByClass('highlight__button-group') !== null,
266+
'the button group sits in the same toolbar row as the caption')
267+
}
268+
216269
// RUN 出力など、後から生成される pre にもボタンを付けられる公開フック。
217270
// getText はクリック時に評価されるので、内容が変わる要素にも使える
218271
{
219272
const spy = clipboardSpy()
220273
const pre = new FakeElement('pre', '')
221-
runOnload([pre], spy.navigator)
274+
const root = runOnload([pre], spy.navigator)
222275
assert(typeof globalThis.window.ruremaAddCopyButton === 'function',
223276
'window.ruremaAddCopyButton is exposed for dynamically created pre')
277+
// 実際の run.js と同様、DOM に挿入してからフックを呼ぶ
224278
const output = new FakeElement('pre', 'highlight__run-output')
279+
root.appendChild(output)
225280
let current = 'first output\n'
226281
const btn = globalThis.window.ruremaAddCopyButton(output, () => current)
227-
assert(output.firstChild === btn,
228-
'the hook prepends a COPY button to the given element')
282+
assert(toolbarOf(output) !== null &&
283+
buttonGroupOf(output).childByClass('highlight__copy-button') === btn,
284+
'the hook inserts a toolbar with the COPY button before the element')
285+
// 既にツールバーがある要素にもう一度呼んでも、ツールバーは1つのまま再利用される
286+
globalThis.window.ruremaAddCopyButton(output, () => current)
287+
assert(root.children.filter(
288+
c => c.className === 'highlight__toolbar').length === 2,
289+
'an existing toolbar is reused (one for the sample, one for the output)')
229290
btn.onclick()
230291
await settle()
231292
current = 'second output\n'
@@ -239,6 +300,8 @@ async function settle() {
239300
// Clipboard API が無い環境では textarea + execCommand にフォールバックする
240301
{
241302
const pre = new FakeElement('pre', '', 'fallback code\n')
303+
const root = new FakeElement('div')
304+
root.appendChild(pre)
242305
const elements = [pre]
243306
globalThis.document = makeDocument(elements)
244307
let captured = null
@@ -253,7 +316,7 @@ async function settle() {
253316
globalThis.navigator = {} // clipboard なし
254317
;(0, eval)(source)
255318
globalThis.window.onload()
256-
const btn = pre.childByClass('highlight__copy-button')
319+
const btn = copyButtonOf(pre)
257320
btn.onclick()
258321
await settle()
259322
assert(captured === 'fallback code\n',

0 commit comments

Comments
 (0)