) but keeps interior/trailing newlines intact
+assert(editableText('\np 1\np 2\n') === 'p 1\np 2\n',
+ 'editableText strips the leading newline artifact')
+assert(editableText('\n\n\np 1\n') === 'p 1\n',
+ 'editableText strips all leading blank lines')
+assert(editableText('p 1\n\np 2\n') === 'p 1\n\np 2\n',
+ 'editableText keeps interior and trailing newlines')
+
if (failures > 0) {
throw new Error(failures + ' JS test(s) failed')
}
diff --git a/test/js/test_script.mjs b/test/js/test_script.mjs
index d112531..7d4eed3 100644
--- a/test/js/test_script.mjs
+++ b/test/js/test_script.mjs
@@ -37,11 +37,16 @@ class FakeElement {
if (name === 'class') this.className = value
}
select() {}
+ // 実 DOM 同様、挿入時は元の親から外して付け替える(parentNode も追跡)
appendChild(child) {
+ if (child.parentNode) child.parentNode.removeChild(child)
+ child.parentNode = this
this.children.push(child)
return child
}
insertBefore(child, ref) {
+ if (child.parentNode) child.parentNode.removeChild(child)
+ child.parentNode = this
const i = this.children.indexOf(ref)
if (ref == null || i < 0) this.children.push(child)
else this.children.splice(i, 0, child)
@@ -49,12 +54,21 @@ class FakeElement {
}
removeChild(child) {
const i = this.children.indexOf(child)
- if (i >= 0) this.children.splice(i, 1)
+ if (i >= 0) {
+ this.children.splice(i, 1)
+ child.parentNode = null
+ }
return child
}
get firstChild() {
return this.children.length > 0 ? this.children[0] : null
}
+ get previousElementSibling() {
+ if (!this.parentNode) return null
+ const siblings = this.parentNode.children
+ const i = siblings.indexOf(this)
+ return i > 0 ? siblings[i - 1] : null
+ }
get textContent() {
return this.ownText + this.children.map(c => c.textContent).join('')
}
@@ -108,13 +122,17 @@ function makeDocument(elements) {
const here = import.meta.url.replace(/^file:\/\//, '').replace(/\/[^/]*$/, '')
const source = std.loadFile(here + '/../../theme/default/script.js')
+// script.js は pre の直前(親の子リスト)にツールバーを差し込むので、
+// テスト対象の要素は root(body 相当)にぶら下げて親を持たせる
function runOnload(elements, navigatorFake) {
+ const root = new FakeElement('div')
+ elements.forEach((e) => root.appendChild(e))
globalThis.document = makeDocument(elements)
globalThis.window = { setTimeout() { return 0 } }
globalThis.navigator = navigatorFake || {}
;(0, eval)(source)
globalThis.window.onload()
- return elements
+ return root
}
// Clipboard API を捕捉する navigator フェイク
@@ -139,15 +157,35 @@ async function settle() {
await 0
}
+// pre の直前のツールバー行 → ボタン置き場 → COPY を辿るヘルパー
+function toolbarOf(elem) {
+ const prev = elem.previousElementSibling
+ if (prev && prev.className.split(' ').indexOf('highlight__toolbar') >= 0) return prev
+ return null
+}
+function buttonGroupOf(elem) {
+ const toolbar = toolbarOf(elem)
+ return toolbar && toolbar.childByClass('highlight__button-group')
+}
+function copyButtonOf(elem) {
+ const group = buttonGroupOf(elem)
+ return group && group.childByClass('highlight__copy-button')
+}
+
// A ruby sample keeps getting the COPY button (regression)
{
const spy = clipboardSpy()
const pre = new FakeElement('pre', 'highlight ruby')
pre.appendChild(new FakeElement('code', '', 'puts 1\n'))
runOnload([pre], spy.navigator)
- const btn = pre.childByClass('highlight__copy-button')
+ const btn = copyButtonOf(pre)
assert(btn !== null, 'pre.highlight.ruby gets a COPY button')
- assert(pre.firstChild === btn, 'COPY button is prepended as the first child')
+ assert(toolbarOf(pre) !== null,
+ 'a toolbar row is inserted just before the pre (outside of it)')
+ assert(pre.childByClass('highlight__button-group') === null,
+ 'no button container is injected inside the pre itself')
+ assert(buttonGroupOf(pre).firstChild === btn,
+ 'COPY button lives inside the toolbar button group')
btn.onclick()
await settle()
assert(spy.written.length === 1 && spy.written[0] === 'puts 1\n',
@@ -161,7 +199,7 @@ async function settle() {
const spy = clipboardSpy()
const pre = new FakeElement('pre', '', 'ary = []\n')
runOnload([pre], spy.navigator)
- const btn = pre.childByClass('highlight__copy-button')
+ const btn = copyButtonOf(pre)
assert(btn !== null, 'plain without highlight class gets a COPY button')
btn.onclick()
await settle()
@@ -174,7 +212,7 @@ async function settle() {
const pre = new FakeElement('pre', 'highlight c')
pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
runOnload([pre], clipboardSpy().navigator)
- assert(pre.childByClass('highlight__copy-button') !== null,
+ assert(copyButtonOf(pre) !== null,
'pre.highlight.c gets a COPY button')
}
@@ -183,7 +221,7 @@ async function settle() {
const div = new FakeElement('div', 'highlight')
const pre = new FakeElement('pre', '')
runOnload([div, pre], clipboardSpy().navigator)
- assert(div.childByClass('highlight__copy-button') === null,
+ assert(copyButtonOf(div) === null,
'non-pre element does not get a COPY button')
}
@@ -194,7 +232,7 @@ async function settle() {
pre.appendChild(new FakeElement('span', 'caption', '例'))
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
runOnload([pre], spy.navigator)
- pre.childByClass('highlight__copy-button').onclick()
+ copyButtonOf(pre).onclick()
await settle()
assert(spy.written[0] === 'p 42\n',
'caption text is excluded from the copied text')
@@ -207,25 +245,48 @@ async function settle() {
const spy = clipboardSpy()
const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
runOnload([pre], spy.navigator)
- pre.childByClass('highlight__copy-button').onclick()
+ copyButtonOf(pre).onclick()
await settle()
assert(spy.written[0] === 'puts 1\n',
'copied text is trimmed (leading newlines dropped, trailing squeezed)')
}
+// pre の直前に caption(タブ)があれば、ツールバーの左端に取り込まれる
+{
+ const caption = new FakeElement('span', 'caption', '例')
+ const pre = new FakeElement('pre', 'highlight ruby')
+ pre.appendChild(new FakeElement('code', '', 'p 42\n'))
+ const root = runOnload([caption, pre], clipboardSpy().navigator)
+ const toolbar = toolbarOf(pre)
+ assert(toolbar !== null && toolbar.firstChild === caption,
+ 'a sibling caption is moved to the left edge of the toolbar')
+ assert(root.children.indexOf(caption) < 0,
+ 'the caption is no longer a direct sibling of the pre')
+ assert(toolbar.childByClass('highlight__button-group') !== null,
+ 'the button group sits in the same toolbar row as the caption')
+}
+
// RUN 出力など、後から生成される pre にもボタンを付けられる公開フック。
// getText はクリック時に評価されるので、内容が変わる要素にも使える
{
const spy = clipboardSpy()
const pre = new FakeElement('pre', '')
- runOnload([pre], spy.navigator)
+ const root = runOnload([pre], spy.navigator)
assert(typeof globalThis.window.ruremaAddCopyButton === 'function',
'window.ruremaAddCopyButton is exposed for dynamically created pre')
+ // 実際の run.js と同様、DOM に挿入してからフックを呼ぶ
const output = new FakeElement('pre', 'highlight__run-output')
+ root.appendChild(output)
let current = 'first output\n'
const btn = globalThis.window.ruremaAddCopyButton(output, () => current)
- assert(output.firstChild === btn,
- 'the hook prepends a COPY button to the given element')
+ assert(toolbarOf(output) !== null &&
+ buttonGroupOf(output).childByClass('highlight__copy-button') === btn,
+ 'the hook inserts a toolbar with the COPY button before the element')
+ // 既にツールバーがある要素にもう一度呼んでも、ツールバーは1つのまま再利用される
+ globalThis.window.ruremaAddCopyButton(output, () => current)
+ assert(root.children.filter(
+ c => c.className === 'highlight__toolbar').length === 2,
+ 'an existing toolbar is reused (one for the sample, one for the output)')
btn.onclick()
await settle()
current = 'second output\n'
@@ -239,6 +300,8 @@ async function settle() {
// Clipboard API が無い環境では textarea + execCommand にフォールバックする
{
const pre = new FakeElement('pre', '', 'fallback code\n')
+ const root = new FakeElement('div')
+ root.appendChild(pre)
const elements = [pre]
globalThis.document = makeDocument(elements)
let captured = null
@@ -253,7 +316,7 @@ async function settle() {
globalThis.navigator = {} // clipboard なし
;(0, eval)(source)
globalThis.window.onload()
- const btn = pre.childByClass('highlight__copy-button')
+ const btn = copyButtonOf(pre)
btn.onclick()
await settle()
assert(captured === 'fallback code\n',
diff --git a/test/test_run_worker_prelude.rb b/test/test_run_worker_prelude.rb
new file mode 100644
index 0000000..61c062a
--- /dev/null
+++ b/test/test_run_worker_prelude.rb
@@ -0,0 +1,83 @@
+require 'test/unit'
+
+# theme/default/js/run-worker.js の PRELUDE(Ruby コード)の検証。
+# Kernel#puts/print/p は $stdout が IO でないとき #write 経由で書き込むが、
+# マニュアルのサンプルは $stderr.puts や $stdout.putc のように IO のメソッドを
+# 直接呼ぶこともある(旧実装の StringIO はそれらを備えていた)。JS ブリッジを
+# スタブした実 Ruby で PRELUDE を eval し、両方の経路の出力を確認する。
+class TestRunWorkerPrelude < Test::Unit::TestCase
+ WORKER_JS = File.expand_path('../theme/default/js/run-worker.js', __dir__)
+
+ module JSStub
+ OUTPUT = []
+ def self.global
+ self
+ end
+
+ def self.call(name, text)
+ raise ArgumentError, "unexpected JS call: #{name}" unless name == :postOutput
+ OUTPUT << text.to_s
+ end
+ end
+
+ def setup
+ src = File.read(WORKER_JS)
+ prelude = src[/export const PRELUDE = `\n(.*?)\n`/m, 1]
+ assert_not_nil(prelude, 'PRELUDE not found in run-worker.js')
+ # ruby.wasm 上でだけ存在する js gem をスタブに差し替える
+ @prelude = prelude.sub(/\Arequire "js"\n/, '')
+ JSStub::OUTPUT.clear
+ Object.const_set(:JS, JSStub)
+ end
+
+ def teardown
+ Object.send(:remove_const, :JS) if Object.const_defined?(:JS)
+ Object.send(:remove_const, :JSStreamIO) if Object.const_defined?(:JSStreamIO)
+ end
+
+ def run_with_prelude
+ orig_stdout = $stdout
+ orig_stderr = $stderr
+ begin
+ eval(@prelude, TOPLEVEL_BINDING)
+ yield
+ ensure
+ $stdout = orig_stdout
+ $stderr = orig_stderr
+ end
+ JSStub::OUTPUT.join
+ end
+
+ def test_kernel_methods_stream_through_post_output
+ out = run_with_prelude do
+ puts 'hello'
+ print 'a', 'b'
+ p 123
+ end
+ assert_equal("hello\nab123\n", out)
+ end
+
+ def test_direct_io_methods_match_old_stringio_behavior
+ out = run_with_prelude do
+ $stdout.puts 'direct'
+ $stderr.puts 'err'
+ $stdout.putc 'XY'
+ $stdout.print 'pr'
+ $stdout.printf('%05d', 42)
+ $stdout << 'chained' << '!'
+ $stdout.flush
+ $stdout.puts ['a', ['b']]
+ $stdout.puts
+ end
+ assert_equal("direct\nerr\nXpr00042chained!a\nb\n\n", out)
+ end
+
+ def test_stream_reports_not_a_tty_and_accepts_sync
+ run_with_prelude do
+ assert_false($stdout.tty?)
+ assert_false($stderr.isatty)
+ assert_true($stdout.sync)
+ $stdout.sync = true
+ end
+ end
+end
diff --git a/test/test_statichtml_command.rb b/test/test_statichtml_command.rb
index 41932a3..d661364 100644
--- a/test/test_statichtml_command.rb
+++ b/test/test_statichtml_command.rb
@@ -57,9 +57,11 @@ def test_run_js_is_copied
FileUtils.mkdir_p(File.join(themedir, 'js'))
FileUtils.mkdir_p(outputdir)
File.write(File.join(themedir, 'js', 'run.js'), "// run\n")
+ File.write(File.join(themedir, 'js', 'run-worker.js'), "// worker\n")
cmd = build_command(themedir, outputdir)
cmd.send(:copy_run_ruby_wasm_script)
assert_true(File.file?(File.join(outputdir, 'js', 'run.js')))
+ assert_true(File.file?(File.join(outputdir, 'js', 'run-worker.js')))
end
end
@@ -74,10 +76,35 @@ def test_theme_without_run_js_is_tolerated
begin
assert_nothing_raised { cmd.send(:copy_run_ruby_wasm_script) }
assert_match(/run\.js not found/, $stderr.string)
+ assert_match(/run-worker\.js not found/, $stderr.string)
ensure
$stderr = orig_stderr
end
assert_false(File.exist?(File.join(outputdir, 'js', 'run.js')))
+ assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js')))
+ end
+ end
+
+ # A themedir with run.js but not yet upgraded with run-worker.js (or vice
+ # versa) should still copy whichever file it does have, rather than
+ # aborting or silently skipping both.
+ def test_partial_theme_copies_what_it_has
+ Dir.mktmpdir do |dir|
+ themedir = File.join(dir, 'theme')
+ outputdir = File.join(dir, 'out')
+ FileUtils.mkdir_p(File.join(themedir, 'js'))
+ FileUtils.mkdir_p(outputdir)
+ File.write(File.join(themedir, 'js', 'run.js'), "// run\n")
+ cmd = build_command(themedir, outputdir)
+ orig_stderr, $stderr = $stderr, StringIO.new
+ begin
+ cmd.send(:copy_run_ruby_wasm_script)
+ assert_match(/run-worker\.js not found/, $stderr.string)
+ ensure
+ $stderr = orig_stderr
+ end
+ assert_true(File.file?(File.join(outputdir, 'js', 'run.js')))
+ assert_false(File.exist?(File.join(outputdir, 'js', 'run-worker.js')))
end
end
end
diff --git a/theme/default/js/run-worker.js b/theme/default/js/run-worker.js
new file mode 100644
index 0000000..34ea6c1
--- /dev/null
+++ b/theme/default/js/run-worker.js
@@ -0,0 +1,111 @@
+// Module Worker that runs one RUN-button sample. Loaded by theme/default/js/run.js
+// via `new Worker(url, { type: 'module' })`; one Worker instance per execution
+// (see run.js for why: a fresh VM per run needs no cross-run state, and it
+// makes terminate()-to-stop trivial). Named .js rather than .mjs for the same
+// MIME-serving reason as run.js (see the comment at the top of that file).
+//
+// Protocol:
+// main -> worker: { module: WebAssembly.Module, code: string }
+// worker -> main: { type: 'output', text: string } (zero or more, in order)
+// worker -> main: { type: 'done' } (exactly one, on success)
+// worker -> main: { type: 'error', message: string } (exactly one, on failure)
+// Exactly one of 'done'/'error' is posted, always last. The main thread may
+// also just call worker.terminate() (STOP button / timeout); the worker does
+// not need to post anything in that case.
+
+const VM_ESM_URL = 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser/+esm'
+
+// Same idea as run.js's old (pre-Worker) PRELUDE -- redirect $stdout/$stderr
+// -- but instead of buffering into a StringIO for one bulk read at the end,
+// each write is forwarded to the main thread immediately via postOutput()
+// so long-running or infinite-output samples show progress instead of
+// growing an in-wasm string forever. $stderr shares the same sink so
+// interleaving still reads naturally, matching the previous StringIO-based
+// behavior. Ruby only ever hands the JS bridge a plain string, so
+// $stdout.write calls self.postOutput(), a small helper defined below
+// (rather than calling self.postMessage directly from Ruby) so that
+// postMessage itself stays the single place that emits the { type: ... }
+// envelope; main.onmessage never has to disambiguate a bare string from a
+// control message.
+//
+// Kernel#puts/print/p reach a non-IO $stdout through its #write, but the
+// manual's samples also call IO methods on $stdout/$stderr *directly*
+// ($stderr.puts, $stdout.putc, $stdout.print, $stdout.flush, $stderr.tty?,
+// ...). The old StringIO provided those for free, so the common ones are
+// implemented here; their semantics are covered by test/test_run_worker_prelude.rb,
+// which evals this prelude in a real Ruby with the JS bridge stubbed.
+export const PRELUDE = `
+require "js"
+class JSStreamIO
+ def write(*parts)
+ text = parts.join
+ JS.global.call(:postOutput, text)
+ text.bytesize
+ end
+ def <<(obj)
+ write(obj)
+ self
+ end
+ def puts(*args)
+ if args.empty?
+ write("\n")
+ else
+ args.flatten.each do |arg|
+ s = arg.to_s
+ s = s + "\n" unless s.end_with?("\n")
+ write(s)
+ end
+ end
+ nil
+ end
+ def print(*args)
+ write(args.join)
+ nil
+ end
+ def printf(*args)
+ write(sprintf(*args))
+ nil
+ end
+ def putc(ch)
+ write(ch.is_a?(String) ? ch[0] : ch.chr)
+ ch
+ end
+ def flush; self; end
+ def sync; true; end
+ def sync=(value); value; end
+ def tty?; false; end
+ alias isatty tty?
+end
+$stdout = JSStreamIO.new
+$stderr = $stdout
+`
+
+export function formatRunError(error) {
+ const text = String((error && error.message) || error)
+ return text.split('\n').slice(0, 20).join('\n')
+}
+
+// Guarded the same way run.js guards its init(): this file is imported by
+// test/js/test_run.mjs under QuickJS, which has neither `self` (as the
+// Worker global) nor a real postMessage/onmessage.
+if (typeof self !== 'undefined' && typeof WorkerGlobalScope !== 'undefined') {
+ self.postOutput = (text) => self.postMessage({ type: 'output', text })
+
+ self.onmessage = async (event) => {
+ const { module, code } = event.data
+ try {
+ const { DefaultRubyVM } = await import(VM_ESM_URL)
+ const { vm } = await DefaultRubyVM(module, { consolePrint: false })
+ vm.eval(PRELUDE)
+ try {
+ vm.eval(code)
+ } catch (e) {
+ self.postMessage({ type: 'error', message: formatRunError(e) })
+ return
+ }
+ self.postMessage({ type: 'done' })
+ } catch (e) {
+ self.postMessage({ type: 'error', message: formatRunError(e) })
+ }
+ }
+}
diff --git a/theme/default/js/run.js b/theme/default/js/run.js
index fe238e9..c76d840 100644
--- a/theme/default/js/run.js
+++ b/theme/default/js/run.js
@@ -1,19 +1,30 @@
// RUN button for Ruby sample code: executes the sample in-browser with
-// ruby.wasm. Named .js rather than .mjs: module scripts are subject to
-// strict MIME checking, and servers whose MIME table lacks an "mjs" entry
-// (e.g. nginx before 1.21.4) serve .mjs as application/octet-stream, which
-// browsers refuse to execute. Enabled per page via
+// ruby.wasm, off the main thread in a Web Worker (theme/default/js/run-worker.js)
+// so an infinite loop or long sleep in the sample can't freeze the page; a
+// STOP button and a timeout both just Worker.terminate() it. Named .js
+// rather than .mjs: module scripts are subject to strict MIME checking, and
+// servers whose MIME table lacks an "mjs" entry (e.g. nginx before 1.21.4)
+// serve .mjs as application/octet-stream, which browsers refuse to execute.
+// The same reasoning applies to run-worker.js. Enabled per page via
//
// which the layout emits when statichtml was invoked with --run-ruby-wasm.
// The wasm URL is chosen by the build side to match the documented Ruby
-// version; this file only pins the loader library.
-
-// Exact pin of the npm "latest" dist-tag at the time of writing. Note the
-// version scheme: stable @ruby/* packages are published as
-// "-" (e.g. 2.9.3-2.9.4); plain "2.9.4"
-// does not exist on npm.
-const VM_ESM_URL = 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser/+esm'
+// version. This file only compiles that wasm (WebAssembly.compileStreaming
+// needs no loader library, just the bytes); the CDN-hosted @ruby/wasm-wasi
+// loader is pinned and imported inside run-worker.js, which is the only
+// place that actually instantiates a VM from the compiled module.
const OUTPUT_LIMIT = 64 * 1024
+const RUN_TIMEOUT_MS = 30 * 1000
+
+// Resolved relative to this module's own URL (not the page URL), so it keeps
+// working regardless of how deep the current page is under the site root or
+// how custom_js_url() built run.js's own