Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions lib/bitclust/subcommands/statichtml_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -327,18 +327,26 @@ def create_search_index(outputdir, db, fdb)
:verbose => @verbose, :preserve => true)
end

# Copy the RUN-button script (statichtml --run-ruby-wasm). A themedir
# without js/run.js is tolerated: warn and skip instead of aborting the
# whole build.
# Scripts for the RUN-button feature (statichtml --run-ruby-wasm):
# run.js sets up the button and compiles the wasm module on the main
# thread; run-worker.js is the module Worker it spawns per execution
# (see theme/default/js/run.js for why: STOP/timeout both just
# Worker#terminate() it). Both are required for the feature to work.
RUN_RUBY_WASM_JS_FILES = %w[run.js run-worker.js].freeze

# Copy the RUN-button scripts. A themedir missing one of them is
# tolerated: warn and skip instead of aborting the whole build.
def copy_run_ruby_wasm_script
run_js = @manager_config[:themedir] + "js" + "run.js"
unless run_js.file?
$stderr.puts "warning: #{run_js} not found; RUN button script not copied"
return
end
jsdir = @outputdir + "js"
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
FileUtils.cp(run_js.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
RUN_RUBY_WASM_JS_FILES.each do |name|
src = @manager_config[:themedir] + "js" + name
unless src.file?
$stderr.puts "warning: #{src} not found; RUN button script not copied"
next
end
FileUtils.mkdir_p(jsdir) unless jsdir.directory?
FileUtils.cp(src.to_s, jsdir.to_s, :verbose => @verbose, :preserve => true)
end
end

def create_html_file(entry, manager, outputdir, db)
Expand Down
2 changes: 2 additions & 0 deletions sig/bitclust/subcommands/statichtml_command.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ module BitClust

def create_index_html: (Pathname outputdir) -> void

RUN_RUBY_WASM_JS_FILES: Array[String]

def copy_run_ruby_wasm_script: () -> void

SEARCH_JS_FILES: Array[String]
Expand Down
70 changes: 62 additions & 8 deletions test/js/test_run.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
// QuickJS-based tests for the pure logic in theme/default/js/run.js.
// QuickJS-based tests for the pure logic in theme/default/js/run.js and
// theme/default/js/run-worker.js.
// Run with: qjs test/js/test_run.mjs (see the "test:js" rake task)
// Importing the module doubles as a syntax/eval smoke test: its DOM setup is
// guarded by `typeof window !== 'undefined'`.
import { createOnceLoader, PRELUDE, formatRunError, truncateOutput } from '../../theme/default/js/run.js'
// Importing each module doubles as a syntax/eval smoke test: run.js's DOM
// setup is guarded by `typeof window !== 'undefined'`, and run-worker.js's
// Worker setup is guarded by `typeof self !== 'undefined' && typeof
// WorkerGlobalScope !== 'undefined'` -- neither exists under QuickJS, so
// importing either file here never touches a real DOM/Worker.
import {
createOnceLoader, formatRunError, truncateOutput,
accumulateOutput, STOPPED_NOTE, DONE_NOTE, timeoutNote, editableText,
} from '../../theme/default/js/run.js'
import { PRELUDE, formatRunError as formatWorkerRunError } from '../../theme/default/js/run-worker.js'

let failures = 0
function assert(cond, message) {
Expand Down Expand Up @@ -52,11 +60,20 @@ function assert(cond, message) {
'next call after a rejection retries the load')
}

// PRELUDE captures both streams into one StringIO
assert(PRELUDE.includes('require "stringio"') &&
PRELUDE.includes('$stdout = StringIO.new') &&
// PRELUDE (run-worker.js) streams both channels to the main thread via the
// JS bridge, instead of buffering into a StringIO like the old single-eval
// PRELUDE did.
assert(PRELUDE.includes('require "js"') &&
PRELUDE.includes('JS.global.call(:postOutput, text)') &&
PRELUDE.includes('$stdout = JSStreamIO.new') &&
PRELUDE.includes('$stderr = $stdout'),
'PRELUDE redirects $stdout and $stderr into one StringIO')
'PRELUDE redirects $stdout and $stderr through postOutput()')

// run-worker.js's formatRunError is the same shape as run.js's copy (each
// runs in its own realm -- main thread vs. worker -- so it is a small
// intentional duplication rather than an import cycle across the two files)
assert(formatWorkerRunError(new Error('boom')) === 'boom',
'run-worker.js formatRunError matches run.js formatRunError')

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

// accumulateOutput: incremental version of truncateOutput, used to append
// each Worker 'output' message to what is already on screen.
assert(accumulateOutput('foo', 'bar') === 'foobar',
'accumulateOutput appends a chunk to the existing text')
assert(accumulateOutput('', 'first') === 'first',
'accumulateOutput handles an empty starting value')
{
const grown = accumulateOutput('x'.repeat(8), 'y'.repeat(8), 10)
assert(grown === 'x'.repeat(8) + 'y'.repeat(2) + '\n... (truncated)',
'accumulateOutput truncates once the cap is crossed mid-chunk')
}
{
// A chunk arriving after the cap was already hit must be a no-op, not a
// second "... (truncated)" marker or renewed growth.
const alreadyTruncated = truncateOutput('x'.repeat(20), 10)
const next = accumulateOutput(alreadyTruncated, 'more output', 10)
assert(next === alreadyTruncated,
'accumulateOutput drops further chunks once truncated')
}

// STOPPED_NOTE / DONE_NOTE / timeoutNote: the notes appended to the output
// on STOP / normal completion / timeout
assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice')
assert(DONE_NOTE === '(正常終了しました)',
'DONE_NOTE marks a normal completion (visible even with empty output)')
assert(timeoutNote(30) === '(30秒でタイムアウトしました)',
'timeoutNote formats the configured timeout in seconds')

// editableText: flattening for edit drops the compiler's leading newline
// (the artifact after <code>) 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')
}
Expand Down
89 changes: 76 additions & 13 deletions test/js/test_script.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,38 @@ 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)
return child
}
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('')
}
Expand Down Expand Up @@ -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 フェイク
Expand All @@ -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',
Expand All @@ -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 <pre> without highlight class gets a COPY button')
btn.onclick()
await settle()
Expand All @@ -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')
}

Expand All @@ -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')
}

Expand All @@ -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')
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -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',
Expand Down
Loading
Loading