diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 1790b36a..aadec7f3 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -30,8 +30,19 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Test dependency download integrity and cache recovery + run: | + python scripts/wasm/test-downloads.py + python scripts/wasm/test-vienna-release.py - name: Test input, worker lifecycle and CSV handling run: npm test --prefix webinterface + - name: Resolve newest stable ViennaRNA 2.x release and checksum + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python scripts/wasm/vienna-release.py --latest > "$RUNNER_TEMP/vienna-release.json" + cat "$RUNNER_TEMP/vienna-release.json" + echo "WASM_VIENNA_RELEASE=$RUNNER_TEMP/vienna-release.json" >> "$GITHUB_ENV" - name: Cross-compile dependencies and IntaRNA run: bash scripts/wasm/build.sh - name: Check real WASM predictions against the native reference diff --git a/ChangeLog b/ChangeLog index 9a9be5b4..02b0b5f7 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,10 @@ ## Interface and handling +- add selected-interaction vaRRI embeds and reproducible all-output ZIP downloads + to the browser interface; compact parameters into accessible tabs and update + the Freiburg group header and full-width result/footer notes (PR #287 review) + - extend the browser interface with vaRRI styling, prediction/evaluation tasks, multi-FASTA, start indices, CLI parameter groups, selectable CSV columns and additional output previews/downloads; label pair-relative differences mfe Δ @@ -80,6 +84,14 @@ ## Technical changes and Optimizations +- build WASM with ViennaRNA 2.7.2 locally and the newest stable 2.x in CI; + verify published archive checksums, isolate versioned caches and retain + selected dependency provenance and notices (PR #292 review) + +- BUGFIX : restore WASM dependency downloads using the pinned official ViennaRNA + release asset; verify downloads before caching and recover invalid caches + (PR #291) + - annotate inherited virtual methods/destructors with override, preserve compiled WASM asset provenance through staging, and expand protocol/browser validation for the PR #287 review @@ -241,6 +253,62 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261022 Alexander Mitrofanov + * scripts/wasm/{vienna-release.py,vienna-release.json}, build.sh + + Resolve the newest stable ViennaRNA >=2.7.2 and <3 from all official GitHub + release pages in CI; require its release tarball and published SHA-256. + * Pin 2.7.2 for reproducible local builds and accept saved release manifests. + Isolate RNAlib sources/installations and IntaRNA objects by archive identity + and keep Boost/zlib in a separate prefix to prevent reuse or include-path + shadowing of libraries/headers from another release (PR #292). + * scripts/wasm/patch-vienna.py + - Remove the dlib 19.24 compatibility patch; ViennaRNA 2.7.2 bundles dlib 20.0. + * scripts/wasm/{write-build-info.mjs,stage.sh,smoke-protocol.mjs,test-browser.py} + * Record the selected archive URL/checksum, check the compiled ViennaRNA + version, stage matching dependency notices and test actual version exports. + * scripts/wasm/test-vienna-release.py, .github/workflows/build-wasm.yml + + Cover numeric version selection, pagination, major-3/prerelease exclusion, + missing/invalid asset metadata, API failures, manifest validation and + rejection of compiled engines with a stale ViennaRNA version. + * webinterface/docs/step1-wasm-compilation.md + * Document latest-2.x CI selection, pinned local reproduction, versioned + caches, current bundled dlib and archive provenance. + +261022 Alexander Mitrofanov + * scripts/wasm/{build,download}.sh + * Use the official GitHub ViennaRNA 2.7.0 release asset and pin its SHA-256; + the university endpoint returned a short HTTP 200 text response, failing + the WASM build before compilation (PR #291). + + Verify archives before caching, discard invalid cached responses and remove + failed partial downloads so rerunning a build can recover automatically. + * scripts/wasm/test-downloads.py, .github/workflows/build-wasm.yml + + Test verified-cache reuse, invalid-cache recovery, checksum failures, + HTTP errors and truncated transfers against a local HTTP server in CI. + * webinterface/docs/step1-wasm-compilation.md + * Document the release source, archive verification and download regression + command, including recovery behavior for failed or outdated caches. + +261022 Alexander Mitrofanov + * webinterface/{archive,visualization}.js + + Build local UTF-8 ZIPs with normalized FASTA, compiled version provenance, + portable parameter files, selected CSV, auxiliary inputs and all outputs; + include separate replay configurations for native text report modes. + + Construct selected-row vaRRI URLs from full target/query sequences, native + dot-bracket output, names and signed indices, preserving strand direction. + * webinterface/{app,results,protocol}.js, index.html, styles.css + + Add row selection, embedded vaRRI/full-viewer link and Download ZIP action; + preserve completed-run inputs and clear exports/viewer when inputs change. + * Replace folded groups with keyboard-accessible tabs and compact parameter + grids; link Bioinformatics - University of Freiburg to the group website + and remove artificial width limits from table/footer notes. + * scripts/wasm/{stage.sh,smoke-protocol.mjs,test-browser.py} + * Stage the new modules; verify byte-identical replay through fresh engines + and browser tab/selection/ZIP behavior in Chromium and Firefox. + * webinterface/tests/export.test.mjs, README.md, webinterface/docs/* + + Cover coordinate/identity mapping, effective settings and archive inputs; + document replay commands, external visualization and browser limitations + (https://github.com/BackofenLab/IntaRNA/pull/287#issuecomment-6321503136). + 261021 Alexander Mitrofanov * src/IntaRNA/*.h * Mark 194 inherited virtual methods and 45 derived destructors override; diff --git a/README.md b/README.md index 1200e7fb..3fd6a84d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ seeding of interaction sites** Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/webinterface/). +Select an interaction to view it in vaRRI, or download a ZIP with inputs, compiled versions, reproducible parameters and all outputs. See the [WebAssembly build instructions](webinterface/docs/step1-wasm-compilation.md) and [interface/deployment notes](webinterface/docs/step4-results-and-deployment.md). diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index b6dd493d..10499d68 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -5,34 +5,42 @@ repo=$(cd "$(dirname "$0")/../.." && pwd) build=${WASM_BUILD_DIR:-"$repo/build-wasm"} mkdir -p "$build" build=$(cd "$build" && pwd) -prefix="$build/deps" +# Keep legacy RNAlib headers out of Boost's -I path: ordinary -I searches take +# precedence over the -isystem ViennaRNA paths produced by IntaRNA's configure. +prefix="$build/deps/common" jobs=${JOBS:-2} -for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node; do +for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node python3; do command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } done mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" fetch() { - local url=$1 name=$2 checksum=$3 - if [[ ! -f "$build/downloads/$name" ]]; then - curl --fail --location --retry 3 "$url" -o "$build/downloads/$name.part" - mv "$build/downloads/$name.part" "$build/downloads/$name" - fi - echo "$checksum $build/downloads/$name" | sha256sum --check - + bash "$repo/scripts/wasm/download.sh" "$1" "$build/downloads/$2" "$3" } fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 -fetch https://www.tbi.univie.ac.at/RNA/download/sourcecode/2_7_x/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ - 9a99fd68ed380894defb4d5e6a8a2871629270028cdf7f16f0a05da6e8c71473 +# CI supplies the newest stable 2.x manifest; local builds use the checked-in pin. +read -r vienna_version vienna_url vienna_sha256 < <( + python3 "$repo/scripts/wasm/vienna-release.py" --fields \ + --manifest "${WASM_VIENNA_RELEASE:-$repo/scripts/wasm/vienna-release.json}" +) +echo "Building ViennaRNA $vienna_version (SHA-256 $vienna_sha256)" +fetch "$vienna_url" "ViennaRNA-$vienna_version.tar.gz" "$vienna_sha256" +# Separate source, installed RNAlib and IntaRNA objects by archive identity so +# changing the release cannot reuse headers, libraries or objects from an old one. +vienna_id="vienna-$vienna_version-$vienna_sha256" +vienna_prefix="$build/deps/$vienna_id" +vienna_sources="$build/sources/$vienna_id" +intarna_build="$build/intarna-$vienna_id" # Keep pkg-config searches inside the WASM prefix, including configure probes. -export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" +export PKG_CONFIG_LIBDIR="$vienna_prefix/lib/pkgconfig:$prefix/lib/pkgconfig" export PKG_CONFIG_PATH="$PKG_CONFIG_LIBDIR" export CFLAGS='-O3 -fexceptions' export CXXFLAGS='-O3 -fexceptions' -export CPPFLAGS="-I$prefix/include" -export LDFLAGS="-L$prefix/lib -fexceptions" +export CPPFLAGS="-I$vienna_prefix/include -I$prefix/include" +export LDFLAGS="-L$vienna_prefix/lib -L$prefix/lib -fexceptions" if [[ ! -f "$prefix/lib/libz.a" ]]; then tar -xzf "$build/downloads/zlib-1.3.1.tar.gz" -C "$build/sources" (cd "$build/sources/zlib-1.3.1" @@ -52,27 +60,26 @@ if [[ ! -f "$prefix/.boost-1.87.0-js-exceptions-complete" ]]; then -sZLIB_LIBPATH="$prefix/lib" --prefix="$prefix" install) touch "$prefix/.boost-1.87.0-js-exceptions-complete" fi -if [[ ! -f "$prefix/lib/libRNA.a" ]]; then - tar -xzf "$build/downloads/ViennaRNA-2.7.0.tar.gz" -C "$build/sources" - (cd "$build/sources/ViennaRNA-2.7.0" - CPPFLAGS="$CPPFLAGS -I$PWD/src" emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$prefix" \ +if [[ ! -f "$vienna_prefix/.complete" ]]; then + mkdir -p "$vienna_sources" + tar -xzf "$build/downloads/ViennaRNA-$vienna_version.tar.gz" -C "$vienna_sources" + (cd "$vienna_sources/ViennaRNA-$vienna_version" + CPPFLAGS="$CPPFLAGS -I$PWD/src" emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$vienna_prefix" \ --disable-shared --enable-static --disable-openmp --disable-pthreads \ --disable-lto --disable-simd --disable-vectorize --disable-mpfr \ --without-svm --without-gsl --without-swig --without-perl --without-python \ --without-doc --without-cla --without-check --without-kinfold \ --without-forester --without-rnalocmin --without-rnaxplorer - # Backport dlib's upstream char32_t typedef for modern libc++ char_traits. - # Only the downloaded dependency is patched; RNAlib algorithms are unchanged. - python3 "$repo/scripts/wasm/patch-vienna.py" "$PWD" emmake make -C src/ViennaRNA -j "$jobs" emmake make -C src/ViennaRNA install emmake make install-pkgconfigDATA) + touch "$vienna_prefix/.complete" fi # Legacy Autoconf probes declare exit()/inflateEnd() with the wrong signatures, # which wasm-ld rejects. Verify real dependency APIs before caching those probes. -em++ -std=c++23 -O2 -fexceptions -I"$prefix/include" \ - "$repo/scripts/wasm/dependency-probe.cpp" -L"$prefix/lib" \ +em++ -std=c++23 -O2 -fexceptions -I"$vienna_prefix/include" -I"$prefix/include" \ + "$repo/scripts/wasm/dependency-probe.cpp" -L"$vienna_prefix/lib" -L"$prefix/lib" \ -lboost_filesystem -lboost_system -lboost_program_options -lboost_regex \ -lboost_iostreams -lboost_serialization -lRNA -lz -o "$build/dependency-probe.js" node "$build/dependency-probe.js" @@ -83,15 +90,15 @@ export ac_cv_lib_boost_regex_exit=yes export ac_cv_lib_boost_iostreams_exit=yes export ac_cv_lib_z_inflateEnd=yes # RNAlib's .pc embeds a native-only -Wl,-fno-lto; this is a static WASM build. -export VRNA_LIBS="-L$prefix/lib -lRNA -lm" +export VRNA_LIBS="-L$vienna_prefix/lib -lRNA -lm" # Use the native Autotools source lists and CLI entry point, with a JS output. (cd "$repo" && bash autotools-init.sh) -mkdir -p "$build/intarna" -(cd "$build/intarna" - CXXFLAGS="$CXXFLAGS -Werror=inconsistent-missing-override" CPPFLAGS="-I$build/intarna/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ +mkdir -p "$intarna_build" +(cd "$intarna_build" + CXXFLAGS="$CXXFLAGS -Werror=inconsistent-missing-override" CPPFLAGS="-I$intarna_build/src -I$repo/src $CPPFLAGS" emconfigure "$repo/configure" --host=wasm32-unknown-emscripten \ --disable-multithreading --disable-log-coloring --with-mdspan=kokkos \ - --with-vrna="$prefix" --with-boost="$prefix" \ + --with-vrna="$vienna_prefix" --with-boost="$prefix" \ --with-boost-libdir="$prefix/lib" --with-zlib="$prefix" emmake make -C src -j "$jobs" EXEEXT=.js \ LDFLAGS="$LDFLAGS -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createIntaRNA -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0 -sEXIT_RUNTIME=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1073741824 -sSTACK_SIZE=8388608" @@ -101,5 +108,6 @@ mkdir -p "$build/intarna" printf '{"type":"module"}\n' > "$build/site/wasm/package.json" node "$repo/scripts/wasm/write-build-info.mjs" "$repo" "$build/site" \ "$(emcc -dumpversion)" "$(git -C "$repo" rev-parse HEAD)" \ - "$(git -C "$repo" status --porcelain --untracked-files=no)" + "$(git -C "$repo" status --porcelain --untracked-files=no)" \ + "$vienna_version" "$vienna_url" "$vienna_sha256" echo "WASM assets: $build/site/wasm" diff --git a/scripts/wasm/download.sh b/scripts/wasm/download.sh new file mode 100644 index 00000000..359f7142 --- /dev/null +++ b/scripts/wasm/download.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cache only verified dependency archives; recover from invalid cached responses. +set -euo pipefail +url=$1 +destination=$2 +checksum=$3 + +# Verify existing archives too, including caches from earlier build scripts. +if [[ -f "$destination" ]]; then + if printf '%s %s\n' "$checksum" "$destination" | sha256sum --check --status; then + echo "$destination: OK (cached)" + exit 0 + fi + echo "Discarding invalid cached archive: $destination" >&2 + rm -- "$destination" +fi + +# Never promote HTTP error pages or incomplete transfers to the download cache. +partial="$destination.part" +trap 'rm -f -- "$partial"' EXIT +curl --fail --location --retry 3 "$url" -o "$partial" +printf '%s %s\n' "$checksum" "$partial" | sha256sum --check - +mv -- "$partial" "$destination" diff --git a/scripts/wasm/patch-vienna.py b/scripts/wasm/patch-vienna.py deleted file mode 100644 index 4b636aad..00000000 --- a/scripts/wasm/patch-vienna.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Apply narrow dlib 19.24 compatibility fixes in the downloaded ViennaRNA tree. - -Modern libc++ only defines char_traits for standard character types. dlib now -uses char32_t upstream. Its old serialization overloads and dependent template -call also need the matching C++ fixes. RNA models and recurrences are untouched. -""" -import sys -from pathlib import Path - -root = Path(sys.argv[1]) / "src/dlib-19.24/dlib" -patches = [ - ("unicode/unicode.h", "typedef uint32 unichar;", "typedef char32_t unichar;"), - ("serialize.h", " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)", - " USE_DEFAULT_INT_SERIALIZATION_FOR(wchar_t)\n USE_DEFAULT_INT_SERIALIZATION_FOR(char32_t)"), - ("global_optimization/find_max_global.h", "::template go(std::forward(f),a)", - "::template go(std::forward(f),a)"), -] -for filename, before, after in patches: - path = root / filename - source = path.read_text() - if after in source: - continue - if source.count(before) != 1: - raise SystemExit(f"Unexpected dlib source: {path}; review compatibility patch") - path.write_text(source.replace(before, after)) diff --git a/scripts/wasm/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs index d3947aef..ee27ad8b 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -1,12 +1,15 @@ // Integration coverage of browser argument translation against the real CLI. import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { DEFAULTS, EVALUATION_EXAMPLE, prepareRequest, makeArguments } from '../../webinterface/protocol.js'; import { CSV_CATALOG } from '../../webinterface/columns.js'; -import { parseResults } from '../../webinterface/results.js'; +import { parseResults, projectCsv } from '../../webinterface/results.js'; +import { archiveFiles } from '../../webinterface/archive.js'; const asset = resolve(process.argv[2] || 'build-wasm/site/wasm/intarna.js'); const { default: createIntaRNA } = await import(pathToFileURL(asset)); +const compiledVersion = readFileSync(new URL('../build-info.txt', pathToFileURL(asset)), 'utf8'); async function run(input, format = 'C') { const request = prepareRequest({ ...DEFAULTS, ...input }); const diagnostics = []; @@ -18,7 +21,7 @@ async function run(input, format = 'C') { const args = makeArguments(request, format); assert.equal(module.callMain(args), 0, `${args.join(' ')}\n${diagnostics.join('\n')}`); const text = module.FS.readFile(format === 'C' ? '/results.csv' : '/report.txt', { encoding: 'utf8' }); - return { rows: format === 'C' ? parseResults(text) : [], text, fs: module.FS }; + return { rows: format === 'C' ? parseResults(text) : [], text, fs: module.FS, request }; } const defaults = await run({ parameters: { outNumber: 10 } }); assert.equal(defaults.rows.length, 10); @@ -45,3 +48,25 @@ const imported = await run({ parameters: { qAcc: 'P', qAccFile: accessibility.fs assert.equal(imported.rows[0].E, (await run({ parameters: { qIntLenMax: 9 } })).rows[0].E); for (const parameters of [{ energyVRNA: 'Turner99', temperature: 20 }, { energyVRNA: 'Andronescu07' }, { model: 'B' }, { mode: 'K' }, { noSeed: true, mode: 'M' }, { seedMaxUP: 1 }, { qAccConstr: '..........', qShape: '1 0.2\n2 0.3' }, { outOverlap: 'N', outNumber: 3 }, { outCsvSort: 'E', outNumber: 3 }]) assert.ok((await run({ parameters })).rows.length >= 1); console.log('Browser protocol: native-reference prediction/evaluation, signed coordinates, multi-FASTA, all columns, imported accessibility, energy/seed/helix/SHAPE options and output files passed'); + +// Execute exported parameter files in fresh engines with only archived inputs. +// Byte comparisons cover selected CSV, per-record extra files and report modes. +async function replayArchive(input) { + const original = await run(input), { request, fs } = original; + const outputs = fs.readdir('/output').filter((name) => name !== '.' && name !== '..').map((name) => ({ name, text: fs.readFile(`/output/${name}`, { encoding: 'utf8' }) })); + const reports = [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']].filter(([key]) => request.outputs.includes(key)); + for (const [key, format] of reports) outputs.push({ name: `intarna-${key}.txt`, text: (await run(input, format)).text }); + const files = archiveFiles(request, { csv: original.text, files: outputs }, compiledVersion); + for (const config of ['parameters', ...reports.map(([key]) => key)]) { + const diagnostics = []; + const module = await createIntaRNA({ noInitialRun: true, locateFile: () => new URL('./intarna.wasm', pathToFileURL(asset)).href, print: (line) => diagnostics.push(line), printErr: (line) => diagnostics.push(line) }); + for (const file of files.filter((file) => /\.cfg$|^(query|target)\.fa$|^[qt](Shape|AccFile)\.txt$/.test(file.name))) module.FS.writeFile(`/${file.name}`, file.text); + assert.equal(module.callMain([`--parameterFile=${config}.cfg`]), 0, diagnostics.join('\n')); + const expected = config === 'parameters' ? [{ name: 'intarna-interactions.csv', text: projectCsv(original.text, request.csvColumns) }, ...outputs.filter((file) => !file.name.startsWith('intarna-'))] : outputs.filter((file) => file.name === `intarna-${config}.txt`); + for (const file of expected) assert.equal(module.FS.readFile(`/${file.name}`, { encoding: 'utf8' }), file.text, file.name); + } +} +await replayArchive({ query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5, parameters: { temperature: 20, outNumber: 3, noSeed: true }, outputs: ['qMinE', 'tPu', 'normal', 'detailed'], csvColumns: ['id2', 'E'] }); +await replayArchive({ task: 'evaluation', rri: EVALUATION_EXAMPLE, parameters: { qShape: '1 0.2\n2 0.3' }, outputs: ['detailed', 'ensemble'] }); +await replayArchive({ parameters: { qAcc: 'P', qAccFile: accessibility.fs.readFile('/output/qPu.csv', { encoding: 'utf8' }), model: 'P' }, outputs: ['bpProb', 'bpsvg'], csvColumns: ['E'] }); +console.log('ZIP parameter replay: CSV, multi-FASTA/signed indices, non-default energy/seed options, SHAPE/accessibility imports, per-record outputs, probability SVG and ASCII/detailed/ensemble reports are byte-identical'); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index 148ae02e..20367c2e 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -6,9 +6,27 @@ build=${WASM_BUILD_DIR:-"$repo/build-wasm"} site="$build/site" test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" +# Select notices from the archive used by these assets, including dlib upgrades. +read -r vienna_version vienna_sha256 dlib_directory < <(python3 - "$site" "$build" <<'PYSOURCE' +import json, re, sys +from pathlib import Path +site, build = map(Path, sys.argv[1:]) +metadata = json.loads((site / "build-info.json").read_text()) +archive = metadata["viennarnaArchive"] +version, digest = archive["version"], archive["sha256"] +if version != metadata["viennarna"] or not re.fullmatch(r"2\.\d+\.\d+", version) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise SystemExit("Invalid compiled ViennaRNA archive identity") +source = build / "sources" / f"vienna-{version}-{digest}" / f"ViennaRNA-{version}" +dlib = list((source / "src").glob("dlib-*/dlib/LICENSE.txt")) +if len(dlib) != 1 or not re.fullmatch(r"dlib-[0-9.]+", dlib[0].parent.parent.name): + raise SystemExit("Expected one bundled dlib license in the compiled ViennaRNA source") +print(version, digest, dlib[0].parent.parent.name) +PYSOURCE +) +vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version" mkdir -p "$site/licenses" cp -R "$repo/webinterface/assets" "$site/" -for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js; do +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js visualization.js; do cp "$repo/webinterface/$file" "$site/$file" done cp "$repo/webinterface/intarna-worker.js" "$site/intarna-worker.js" @@ -18,20 +36,20 @@ cp "$repo/src/easylogging++.LICENCE.txt" "$site/licenses/easylogging.txt" cp "$repo/src/mdspan/LICENSE" "$site/licenses/mdspan.txt" cp "$build/sources/boost_1_87_0/LICENSE_1_0.txt" "$site/licenses/Boost.txt" cp "$build/sources/zlib-1.3.1/zlib.h" "$site/licenses/zlib.h" -cp "$build/sources/ViennaRNA-2.7.0/COPYING" "$site/licenses/ViennaRNA.txt" -cp "$build/sources/ViennaRNA-2.7.0/src/dlib-19.24/dlib/LICENSE.txt" "$site/licenses/dlib.txt" -cp "$build/sources/ViennaRNA-2.7.0/src/ViennaRNA/plotting/naview/naview.c" "$site/licenses/naview.c" +cp "$vienna_source/COPYING" "$site/licenses/ViennaRNA.txt" +cp "$vienna_source/src/$dlib_directory/dlib/LICENSE.txt" "$site/licenses/dlib.txt" +cp "$vienna_source/src/ViennaRNA/plotting/naview/naview.c" "$site/licenses/naview.c" emscripten_dir=$(dirname "$(command -v emcc)") cp "$emscripten_dir/LICENSE" "$site/licenses/Emscripten.txt" cp "$emscripten_dir/system/lib/libc/musl/COPYRIGHT" "$site/licenses/musl.txt" for library in libcxx libcxxabi libunwind compiler-rt; do cp "$emscripten_dir/system/lib/$library/LICENSE.TXT" "$site/licenses/$library.txt" done -cat > "$site/licenses/index.html" <<'HTML' +cat > "$site/licenses/index.html" <IntaRNA licenses

IntaRNA and third-party notices

IntaRNA: Bioinformatics Group of Prof. Backofen, University of Freiburg.
ViennaRNA: authors and Institute for Theoretical Chemistry, University of Vienna.

-

This distribution uses ViennaRNA 2.7.0, Boost 1.87.0, zlib 1.3.1, bundled dlib 19.24 (with Unicode/template compatibility fixes), Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

+

This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

  • IntaRNA
  • ViennaRNA
  • Boost
  • zlib
  • diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 29b19f11..079fbdf5 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -1,6 +1,10 @@ """Exercise the staged browser interface with real WASM at a nested URL.""" import argparse from pathlib import Path +from io import BytesIO +import re +from urllib.parse import parse_qs, urljoin, urlparse +from zipfile import ZipFile from playwright.sync_api import sync_playwright, expect parser = argparse.ArgumentParser(description=__doc__) @@ -14,6 +18,12 @@ page = browser.new_page(viewport={'width': 1440, 'height': 1100}) errors = [] page.on('pageerror', lambda error: errors.append(str(error))) + # Keep browser tests offline and inspect the exact third-party URL separately. + visualization_urls = [] + def viewer(route): + visualization_urls.append(route.request.url) + route.fulfill(content_type='text/html', body='

    vaRRI viewer

    ') + page.route('https://backofenlab.github.io/vaRRI/**', viewer) page.goto(args.url) page.wait_for_load_state('networkidle') compute = page.locator('#compute') @@ -21,11 +31,17 @@ rows = page.locator('tbody tr') def open_group(key): - group = page.locator('#' + key) - if not group.evaluate('node => node.open'): - group.locator('summary').first.click() - expect(group).to_have_attribute('open', '') - assert page.locator('.parameter-group[open]').count() == 1 + page.locator('#tab-' + key).click() + expect(page.locator('#' + key)).to_be_visible() + expect(page.locator('#tab-' + key)).to_have_attribute('aria-selected', 'true') + assert page.locator('.parameter-group:visible').count() == 1 + + def download_archive(): + with page.expect_download() as downloaded: + page.locator('#download-zip').click() + with ZipFile(BytesIO(Path(downloaded.value.path()).read_bytes())) as archive: + assert archive.testzip() is None + return {name: archive.read(name).decode() for name in archive.namelist()} def fill(key, value): parent = page.locator('#' + key).evaluate('node => node.closest(".parameter-group")?.id') @@ -38,13 +54,27 @@ def predict(): expect(status).to_contain_text('complete:', timeout=120000) expect(page.locator('#input-error')).not_to_be_visible() - expect(page.locator('#version-info')).to_contain_text('ViennaRNA 2.7.0') + metadata = page.request.get(urljoin(args.url, 'build-info.json')).json() + vienna_version = metadata['viennarna'] + assert re.fullmatch(r'2\.\d+\.\d+', vienna_version) + assert metadata['viennarnaArchive']['version'] == vienna_version + expect(page.locator('#version-info')).to_contain_text(f'ViennaRNA {vienna_version}') for logo in page.locator('.site-header img').all(): assert logo.evaluate('node => node.complete && node.naturalWidth > 0') for key in ['query-options', 'target-options', 'seed-options', 'helix-options', 'shape-options', 'interaction-options', 'output-options', 'general-options', 'sequence-input']: open_group(key) - # JavaScript validation works even when the offending control is folded. + # Tabs support keyboard navigation and wrap without page overflow. + page.locator('#tab-sequence-input').focus() + page.keyboard.press('ArrowRight') + expect(page.locator('#tab-query-options')).to_be_focused() + expect(page.locator('#query-options')).to_be_visible() + page.keyboard.press('End') + expect(page.locator('#tab-general-options')).to_be_focused() + page.keyboard.press('Home') + expect(page.locator('#tab-sequence-input')).to_be_focused() + + # JavaScript validation works even when the offending control is in another tab. fill('query', '>bad\nACGU-ACGU') compute.click() expect(page.locator('#input-error')).to_contain_text('use only') @@ -61,10 +91,10 @@ def predict(): page.locator('#outCsvSort').select_option('E') predict() expect(rows).to_have_count(10) - assert rows.first.locator('td').all_text_contents()[:6] == ['target', '1', '10', 'query', '1', '10'] + assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '1', '10', 'query', '1', '10'] assert rows.first.locator('td').all_text_contents()[-2:] == ['-21.40', '0.00'] page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() - assert float(rows.first.locator('td').nth(8).inner_text()) == -14.3 + assert float(rows.first.locator('td').nth(9).inner_text()) == -14.3 page.get_by_role('button', name='Sort by Energy (kcal/mol)', exact=False).click() page.locator('#search').fill('-21.4') expect(rows).to_have_count(1) @@ -81,7 +111,28 @@ def predict(): assert csv.splitlines()[0] == 'id1;start1;end1;id2;start2;end2;subseqDP;hybridDP;E' assert 'target;1;10;query;1;10;' in csv assert 'deltaE' not in csv + assert not visualization_urls # Third-party content loads only on selection. + rows.first.get_by_role('button', name='Visualize').click() + expect(page.locator('#varri-frame')).to_be_visible() + expect(page.frame_locator('#varri-frame').get_by_role('heading', name='vaRRI viewer')).to_be_visible() + expect(rows.first.locator('button')).to_have_attribute('aria-pressed', 'true') + query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + assert query['sequence'] == ['CCCUCCUCCC&GGGAGGAGGG'] + assert query['structure'] == ['((((((((((&))))))))))'] + assert query['seqName1'] == ['target'] and query['seqName2'] == ['query'] + assert query['startIndex1'] == ['1'] + assert query['showRenderingOnly'] == ['true'] + assert 'showRenderingOnly' not in page.locator('#varri-link').get_attribute('href') + archive = download_archive() + assert archive['intarna-interactions.csv'] == csv + assert archive['query.fa'] == '>query\nGGGAGGAGGG\n' + assert '# IntaRNA 3.4.1' in archive['parameters.cfg'] + assert f'Vienna RNA package {vienna_version}' in archive['parameters.cfg'] + assert 'outNumber = 10' in archive['parameters.cfg'] + assert 'IntaRNA --parameterFile=parameters.cfg' in archive['parameters.cfg'] + assert 'temperature =' not in archive['parameters.cfg'] if args.screenshot: + open_group('sequence-input') page.screenshot(path=args.screenshot, full_page=True) page.set_viewport_size({'width': 390, 'height': 844}) @@ -97,15 +148,26 @@ def predict(): page.locator('#output-options input[value="qMinE"]').check() predict() expect(rows).to_have_count(2) - assert rows.first.locator('td').all_text_contents()[:6] == ['target', '-5', '5', 'q1', '-3', '7'] + assert rows.first.locator('td').all_text_contents()[1:7] == ['target', '-5', '5', 'q1', '-3', '7'] expect(page.locator('#file-list details')).to_have_count(2) page.locator('#file-list summary').first.click() expect(page.locator('#file-list pre').first).to_contain_text('minE') + archive = download_archive() + assert len([name for name in archive if name.startswith('qMinE')]) == 2 + assert archive['query.fa'].count('>') == 2 + assert 'qIdxPos0 = -3' in archive['parameters.cfg'] + rows.nth(1).get_by_role('button', name='Visualize').click() + query = parse_qs(urlparse(page.locator('#varri-frame').get_attribute('src')).query) + assert query['seqName2'] == ['q2'] + assert query['startIndex1'] == ['-5'] and query['startIndex2'] == ['-3'] # FASTA identity follows native first-token naming, including collisions. fill('query', '>q first\nGGGAGGAGGG\n>q second\nGGGAGGAGGG') compute.click() expect(page.locator('#input-error')).to_contain_text('unique') + expect(page.locator('#varri-frame')).not_to_be_visible() + assert page.locator('#varri-frame').get_attribute('src') is None + expect(page.locator('#download-zip')).not_to_be_visible() # Evaluation resets native defaults and removes prediction-only controls. fill('query', '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG') @@ -130,6 +192,23 @@ def predict(): with page.expect_download() as report: page.locator('#file-list button').click() assert '-21.4' in Path(report.value.path()).read_text() + archive = download_archive() + assert '-21.4' in archive['intarna-detailed.txt'] + assert 'personality = IntaRNAeval' in archive['parameters.cfg'] + assert 'rri = 1||||||||||&1||||||||||:1|||||||||&2|||||||||' in archive['parameters.cfg'] + assert 'seedBP =' not in archive['parameters.cfg'] + assert 'out = intarna-detailed.txt' in archive['detailed.cfg'] + + # Visualization stays available when structure/identity columns are deselected. + open_group('output-options') + page.get_by_role('button', name='Clear selection', exact=True).click() + page.locator('#output-options input[value="E"]').check() + predict() + rows.first.get_by_role('button', name='Visualize').click() + expect(page.locator('#varri-frame')).to_be_visible() + archive = download_archive() + assert archive['intarna-interactions.csv'].splitlines()[0] == 'E' + assert 'hybridDP' not in archive['parameters.cfg'] fill('rri', '1|||&1||') compute.click() expect(page.locator('#input-error')).to_contain_text('same nonzero') @@ -159,6 +238,8 @@ def predict(): compute.click() expect(status).to_contain_text('No favorable interactions', timeout=120000) expect(rows).to_have_count(0) + expect(page.locator('#varri-frame')).not_to_be_visible() + assert download_archive()['intarna-interactions.csv'].count('\n') == 1 fill('query', '>\nGGGAGGAGGG') predict() assert page.locator('tbody img').count() == 0 @@ -174,4 +255,4 @@ def predict(): predict() assert errors == [], errors browser.close() -print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, columns/reports, mobile and abort/recovery passed') +print(f'{args.browser}: real WASM, groups/defaults/versions, prediction/evaluation, FASTA/coordinates, tabs/keyboard, visualization URLs/selection, ZIP replay contents, columns/reports, mobile and abort/recovery passed') diff --git a/scripts/wasm/test-downloads.py b/scripts/wasm/test-downloads.py new file mode 100644 index 00000000..6b0f7bc3 --- /dev/null +++ b/scripts/wasm/test-downloads.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise archive downloads against a local HTTP server, without Emscripten.""" +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import subprocess +from tempfile import TemporaryDirectory +from threading import Thread +import unittest + + +ARCHIVE = b"pinned dependency archive\n" +CHECKSUM = hashlib.sha256(ARCHIVE).hexdigest() +DOWNLOADER = Path(__file__).with_name("download.sh") + + +class DownloadHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append(self.path) + if self.path == "/missing": + self.send_error(404) + return + body = ARCHIVE if self.path == "/archive" else b"not an archive\n" + self.send_response(200) + self.send_header("Content-Length", str(len(body) + (10 if self.path == "/partial" else 0))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +class DownloadTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), DownloadHandler) + cls.thread = Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def setUp(self): + self.directory = TemporaryDirectory(prefix="intarna-download-") + self.addCleanup(self.directory.cleanup) + # Exercise quoted paths and the same .part/cache names used by builds. + self.destination = Path(self.directory.name) / "dependency archive.tar.gz" + self.server.requests = [] + + def download(self, path="/archive"): + url = f"http://127.0.0.1:{self.server.server_port}{path}" + return subprocess.run( + ["bash", str(DOWNLOADER), url, str(self.destination), CHECKSUM], + capture_output=True, text=True, timeout=30, + ) + + def test_download_and_reuse_verified_cache(self): + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + # A valid cache must work even when the remote archive is unavailable. + result = self.download("/missing") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.server.requests, ["/archive"]) + + def test_replace_invalid_cache_and_stale_partial(self): + self.destination.write_bytes(b"old archive or cached HTTP error") + Path(str(self.destination) + ".part").write_bytes(b"interrupted download") + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + self.assertEqual(self.server.requests, ["/archive"]) + + def test_failed_responses_are_not_cached_and_next_run_recovers(self): + for path in ("/invalid", "/missing", "/partial"): + with self.subTest(path=path): + result = self.download(path) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.destination.exists()) + self.assertFalse(Path(str(self.destination) + ".part").exists()) + result = self.download() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.destination.read_bytes(), ARCHIVE) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/wasm/test-vienna-release.py b/scripts/wasm/test-vienna-release.py new file mode 100644 index 00000000..2bf50995 --- /dev/null +++ b/scripts/wasm/test-vienna-release.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Check stable-major selection, pagination and archive identity without GitHub.""" +from copy import deepcopy +import importlib.util +from io import BytesIO +import json +from pathlib import Path +import subprocess +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch +from urllib.error import HTTPError + + +SCRIPT = Path(__file__).with_name("vienna-release.py") +SPEC = importlib.util.spec_from_file_location("vienna_release", SCRIPT) +resolver = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resolver) +DIGEST = "a" * 64 + + +def release(version, **fields): + return { + "tag_name": f"v{version}", "draft": False, "prerelease": False, + "assets": [{ + "name": f"ViennaRNA-{version}.tar.gz", "digest": f"sha256:{DIGEST}", + "browser_download_url": f"{resolver.REPOSITORY}/releases/download/v{version}/ViennaRNA-{version}.tar.gz", + }], **fields, + } + + +class ReleaseTests(unittest.TestCase): + def resolve(self, pages): + with patch.object(resolver, "urlopen", side_effect=[BytesIO(json.dumps(page).encode()) for page in pages]) as fetch: + result = resolver.latest_release() + return result, fetch + + def test_numeric_selection_excludes_major3_drafts_and_prereleases(self): + result, _ = self.resolve([[ + release("3.0.0"), release("2.9.0"), release("2.10.0"), + release("2.11.0", prerelease=True), release("2.12.0", draft=True), + release("2.13.0-rc.1"), release("2.7.2"), release("2.7.1"), + ]]) + self.assertEqual(result["version"], "2.10.0") + self.assertEqual(result["sha256"], DIGEST) + + def test_pagination_finds_version2_after_one_hundred_newer_releases(self): + result, fetch = self.resolve([[release(f"3.0.{n}") for n in range(100)], [release("2.7.2")]]) + self.assertEqual(result["version"], "2.7.2") + self.assertEqual(fetch.call_count, 2) + self.assertTrue(fetch.call_args.args[0].full_url.endswith("per_page=100&page=2")) + + def test_newest_release_must_have_exact_asset_url_and_digest(self): + latest = release("2.8.0") + for field, value in [("name", "source.tar.gz"), ("digest", None), ("digest", "sha256:bad"), ("digest", "md5:" + DIGEST), ("browser_download_url", "https://example.com/archive.tar.gz")]: + with self.subTest(field=field, value=value): + invalid = deepcopy(latest) + invalid["assets"][0][field] = value + with self.assertRaises(ValueError): + self.resolve([[release("2.7.2"), invalid]]) + with self.assertRaises(ValueError): + self.resolve([[release("2.8.0", assets=latest["assets"] * 2)]]) + + def test_no_supported_release_or_api_failure_is_an_error(self): + with self.assertRaises(ValueError): + self.resolve([[release("3.0.0"), release("2.7.1"), release("2.8.0", prerelease=True)]]) + with patch.object(resolver, "urlopen", side_effect=HTTPError(resolver.API, 403, "rate limited", {}, None)): + with self.assertRaises(HTTPError): + resolver.latest_release() + + def test_checked_in_pin_and_shell_fields(self): + manifest = json.loads(subprocess.check_output(["python3", str(SCRIPT)], text=True)) + self.assertEqual(manifest["version"], "2.7.2") + fields = subprocess.check_output(["python3", str(SCRIPT), "--fields"], text=True).split() + self.assertEqual(fields, list(manifest.values())) + + def test_invalid_pin_fails_without_emitting_shell_fields(self): + manifest = json.loads(SCRIPT.with_suffix(".json").read_text()) + with TemporaryDirectory(prefix="vienna manifest ") as directory: + path = Path(directory) / "release.json" + for field, value in [("version", "3.0.0"), ("version", "2.7.0"), ("url", "https://example.com"), ("sha256", "bad")]: + with self.subTest(field=field): + path.write_text(json.dumps({**manifest, field: value})) + result = subprocess.run(["python3", str(SCRIPT), "--manifest", str(path), "--fields"], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + def test_provenance_rejects_stale_engine_and_records_selected_archive(self): + manifest = json.loads(SCRIPT.with_suffix(".json").read_text()) + with TemporaryDirectory(prefix="vienna engine ") as directory: + site = Path(directory) + assets = site / "wasm" + assets.mkdir() + (assets / "package.json").write_text('{"type":"module"}') + (assets / "intarna.wasm").write_bytes(b"test engine") + for version in ["2.7.0", manifest["version"]]: + (assets / "intarna.js").write_text( + "export default async ({print}) => ({callMain() {" + f"print('IntaRNA 3.4.1 using Vienna RNA package {version} and boost 1.87.0');" + "return 0;}});" + ) + result = subprocess.run([ + "node", str(SCRIPT.with_name("write-build-info.mjs")), + str(SCRIPT.parents[2]), str(site), "4.0.23", "test-commit", "", + manifest["version"], manifest["url"], manifest["sha256"], + ], capture_output=True, text=True) + if version == "2.7.0": + self.assertNotEqual(result.returncode, 0) + self.assertIn("differs from selected", result.stderr) + self.assertFalse((site / "build-info.json").exists()) + else: + self.assertEqual(result.returncode, 0, result.stderr) + metadata = json.loads((site / "build-info.json").read_text()) + self.assertEqual(metadata["viennarna"], version) + self.assertEqual(metadata["viennarnaArchive"], manifest) + self.assertIn(manifest["sha256"], (site / "build-info.txt").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/wasm/vienna-release.json b/scripts/wasm/vienna-release.json new file mode 100644 index 00000000..d54b5517 --- /dev/null +++ b/scripts/wasm/vienna-release.json @@ -0,0 +1,5 @@ +{ + "version": "2.7.2", + "url": "https://github.com/ViennaRNA/ViennaRNA/releases/download/v2.7.2/ViennaRNA-2.7.2.tar.gz", + "sha256": "4cb7aaca00dfac92cb36b030c9111487c170b90568759bd4ffedd8318cc9384f" +} diff --git a/scripts/wasm/vienna-release.py b/scripts/wasm/vienna-release.py new file mode 100644 index 00000000..f869e786 --- /dev/null +++ b/scripts/wasm/vienna-release.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Resolve a stable ViennaRNA 2.x release asset, or validate a pinned manifest.""" +import argparse +import json +import os +from pathlib import Path +import re +from urllib.request import Request, urlopen + + +REPOSITORY = "https://github.com/ViennaRNA/ViennaRNA" +API = "https://api.github.com/repos/ViennaRNA/ViennaRNA/releases" +MINIMUM = (2, 7, 2) + + +def version_tuple(version): + if not isinstance(version, str) or not re.fullmatch(r"2\.\d+\.\d+", version): + raise ValueError("Expected a stable ViennaRNA 2.x version") + result = tuple(map(int, version.split("."))) + if result < MINIMUM: + raise ValueError("The WASM build requires ViennaRNA >= 2.7.2") + return result + + +def validate(manifest): + """Accept only official release tarballs with an explicit SHA-256.""" + version = manifest["version"] + version_tuple(version) + expected = f"{REPOSITORY}/releases/download/v{version}/ViennaRNA-{version}.tar.gz" + if manifest["url"] != expected: + raise ValueError("Expected the official ViennaRNA release asset URL") + if not isinstance(manifest["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", manifest["sha256"]): + raise ValueError("Missing or invalid ViennaRNA archive SHA-256") + return {key: manifest[key] for key in ("version", "url", "sha256")} + + +def latest_release(): + # Enumerate all pages: /latest may select major 3, and release dates need not + # follow version order (for example, a maintenance release of an older minor). + headers = {"Accept": "application/vnd.github+json", "User-Agent": "IntaRNA-WASM"} + if os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}" + candidates = [] + page = 1 + while True: + request = Request(f"{API}?per_page=100&page={page}", headers=headers) + with urlopen(request, timeout=30) as response: + releases = json.load(response) + if not isinstance(releases, list): + raise ValueError("Expected a GitHub release list") + for release in releases: + tag = release.get("tag_name", "") + if release.get("draft") or release.get("prerelease") or not re.fullmatch(r"v2\.\d+\.\d+", tag): + continue + version = tag[1:] + if tuple(map(int, version.split("."))) >= MINIMUM: + candidates.append(release) + if len(releases) < 100: + break + page += 1 + if not candidates: + raise ValueError("No stable ViennaRNA 2.x release >= 2.7.2 was found") + release = max(candidates, key=lambda item: version_tuple(item["tag_name"][1:])) + version = release["tag_name"][1:] + # Fail if the newest release lacks the asset/digest; do not silently select + # an older release or GitHub's differently packaged autogenerated source. + name = f"ViennaRNA-{version}.tar.gz" + assets = [asset for asset in release.get("assets", []) if asset["name"] == name] + if len(assets) != 1: + raise ValueError(f"Expected exactly one {name} release asset") + asset = assets[0] + digest = asset.get("digest") or "" + if not digest.startswith("sha256:"): + raise ValueError(f"Missing published SHA-256 for {name}") + return validate({"version": version, "url": asset["browser_download_url"], "sha256": digest[7:]}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group() + source.add_argument("--latest", action="store_true", help="query the official GitHub releases API") + source.add_argument("--manifest", type=Path, default=Path(__file__).with_suffix(".json")) + parser.add_argument("--fields", action="store_true", help="emit version, URL and SHA-256 for build.sh") + args = parser.parse_args() + try: + manifest = latest_release() if args.latest else validate(json.loads(args.manifest.read_text())) + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(1, f"Cannot resolve ViennaRNA release: {error}\n") + print(" ".join(manifest.values()) if args.fields else json.dumps(manifest, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/wasm/write-build-info.mjs b/scripts/wasm/write-build-info.mjs index 959aacc4..4bf9fd47 100644 --- a/scripts/wasm/write-build-info.mjs +++ b/scripts/wasm/write-build-info.mjs @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -const [repo, site, emscripten, commit, changes] = process.argv.slice(2); +const [repo, site, emscripten, commit, changes, viennaVersion, viennaUrl, viennaSha256] = process.argv.slice(2); const { default: createIntaRNA } = await import(pathToFileURL(resolve(site, 'wasm/intarna.js'))); const lines = []; const module = await createIntaRNA({ noInitialRun: true, locateFile: () => pathToFileURL(resolve(site, 'wasm/intarna.wasm')).href, print: (line) => lines.push(line), printErr: (line) => lines.push(line) }); @@ -11,13 +11,15 @@ if (module.callMain(['--version']) !== 0) throw new Error('Could not read compil const version = lines.join('\n'); const match = /IntaRNA\s+(\S+)[\s\S]*Vienna RNA package\s+(\S+)\s+and boost\s+(\S+)/i.exec(version); if (!match) throw new Error(`Unrecognized engine version output: ${version}`); +if (match[2] !== viennaVersion) throw new Error(`Compiled ViennaRNA ${match[2]} differs from selected ${viennaVersion}`); const metadata = { source: 'https://github.com/BackofenLab/IntaRNA', commit, dirty: !!changes, intarna: match[1], viennarna: match[2], boost: match[3], emscripten, zlib: '1.3.1', + viennarnaArchive: { version: viennaVersion, url: viennaUrl, sha256: viennaSha256 }, threading: 'single worker per task', sha256: Object.fromEntries(['intarna.js', 'intarna.wasm'].map((name) => [name, createHash('sha256').update(readFileSync(resolve(site, 'wasm', name))).digest('hex')])), }; writeFileSync(resolve(site, 'build-info.json'), JSON.stringify(metadata, null, 2) + '\n'); -writeFileSync(resolve(site, 'build-info.txt'), `${version}\nIntaRNA repository: ${metadata.source}\nIntaRNA revision: ${metadata.commit}${metadata.dirty ? ' (modified sources)' : ''}\nEmscripten: ${emscripten}\nzlib: ${metadata.zlib}\n${Object.entries(metadata.sha256).map(([name, hash]) => `SHA-256 ${name}: ${hash}`).join('\n')}\n`); +writeFileSync(resolve(site, 'build-info.txt'), `${version}\nIntaRNA repository: ${metadata.source}\nIntaRNA revision: ${metadata.commit}${metadata.dirty ? ' (modified sources)' : ''}\nEmscripten: ${emscripten}\nzlib: ${metadata.zlib}\nViennaRNA archive: ${viennaUrl}\nViennaRNA archive SHA-256: ${viennaSha256}\n${Object.entries(metadata.sha256).map(([name, hash]) => `SHA-256 ${name}: ${hash}`).join('\n')}\n`); console.log(`Recorded compiled versions: IntaRNA ${match[1]}, ViennaRNA ${match[2]}, Boost ${match[3]}`); diff --git a/webinterface/app.js b/webinterface/app.js index eeb37665..9ecdaecc 100644 --- a/webinterface/app.js +++ b/webinterface/app.js @@ -2,25 +2,49 @@ import { DEFAULTS, EVALUATION_EXAMPLE, parseFasta, prepareRequest, CSV_COLUMNS } import { GROUPS, FIELDS, EXTRA_OUTPUTS } from './options.js'; import { CSV_CATALOG } from './columns.js'; import { PredictionRunner } from './runner.js'; -import { ResultsView, downloadText } from './results.js'; +import { ResultsView, downloadText, downloadBlob } from './results.js'; +import { createArchive } from './archive.js'; const byId = (id) => document.getElementById(id); const form = byId('prediction-form'), runner = new PredictionRunner(); const dialog = byId('loading'), status = byId('result-status'), errorBox = byId('input-error'); const results = new ResultsView(byId('results-view')); const csvInputs = [], outputInputs = []; +let completedRun = null; function element(tag, text, className) { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; } +function activateGroup(key, focus = false) { + for (const tab of byId('parameter-tabs').children) { + const active = tab.getAttribute('aria-controls') === key; + tab.setAttribute('aria-selected', String(active)); tab.tabIndex = active ? 0 : -1; + byId(tab.getAttribute('aria-controls')).hidden = !active; + if (active && focus) tab.focus(); + } +} +function addTab(key, label) { + const tab = element('button', label); tab.type = 'button'; tab.id = `tab-${key}`; + tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', key); + tab.addEventListener('click', () => activateGroup(key)); + byId('parameter-tabs').append(tab); +} function group(key, label) { - const details = element('details', '', 'parameter-group'); - details.id = key; details.name = 'parameters'; details.append(element('summary', label)); - const body = element('div', '', 'group-body'); details.append(body); - byId('parameter-groups').append(details); return body; + const panel = element('section', '', 'parameter-group'); panel.id = key; + panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', `tab-${key}`); + const body = element('div', '', 'group-body'); panel.append(body); + byId('parameter-groups').append(panel); addTab(key, label); return body; } +addTab('sequence-input', 'Sequence input'); +byId('parameter-tabs').addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = [...byId('parameter-tabs').children].filter((tab) => !tab.hidden); + const current = tabs.indexOf(document.activeElement); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + event.preventDefault(); activateGroup(tabs[next].getAttribute('aria-controls'), true); +}); for (const definition of GROUPS) { const body = group(definition.key, definition.label), grid = element('div', '', 'field-grid'); body.append(grid); for (const field of definition.fields) { @@ -69,12 +93,8 @@ const generalBody = group('general-options', 'General'); generalBody.append(element('p', 'Tasks run locally in one worker. Abort or Escape stops computation immediately. Process-level settings (threads, log paths, parameter-file paths and terminal help) are available in the CLI.')); const docs = element('a', 'Full CLI parameter documentation'); docs.href = 'https://github.com/BackofenLab/IntaRNA#readme'; generalBody.append(docs); -// Use explicit grouping as a fallback for browsers without details[name]. -for (const details of document.querySelectorAll('.parameter-group')) { - details.addEventListener('toggle', () => { if (details.open) for (const other of document.querySelectorAll('.parameter-group')) if (other !== details) other.open = false; }); -} function clearResults(message = 'Inputs changed. Compute to update the interactions.') { - results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); + completedRun = null; results.clear(); byId('output-files').hidden = true; byId('file-list').replaceChildren(); byId('diagnostics').hidden = true; byId('diagnostic-text').textContent = ''; status.textContent = message; } function updateLength(name) { @@ -89,7 +109,11 @@ function updateAvailability() { byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : 'Predict interactions for all query–target sequence pairs.'; byId('compute').textContent = evaluation ? 'Evaluate interactions' : 'Compute interactions'; for (const definition of GROUPS) { - const details = byId(definition.key); details.hidden = evaluation && !!definition.prediction; + const tab = byId(`tab-${definition.key}`); tab.hidden = evaluation && !!definition.prediction; + if (tab.hidden) { + byId(definition.key).hidden = true; + if (tab.getAttribute('aria-selected') === 'true') activateGroup('sequence-input'); + } for (const field of definition.fields) { const input = byId(field.key); input.disabled = evaluation && !!field.prediction || !evaluation && definition.key === 'helix-options' && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; @@ -113,7 +137,7 @@ form.addEventListener('input', (event) => { }); byId('example').addEventListener('click', () => { byId('query').value = DEFAULTS.query; byId('target').value = DEFAULTS.target; - resetParameters(); byId('sequence-input').open = true; clearResults('Example loaded. Compute to analyze its interactions.'); + resetParameters(); activateGroup('sequence-input'); clearResults('Example loaded. Compute to analyze its interactions.'); }); byId('abort').addEventListener('click', () => runner.abort()); dialog.addEventListener('cancel', (event) => { event.preventDefault(); runner.abort(); }); @@ -137,13 +161,15 @@ form.addEventListener('submit', async (event) => { csvColumns: csvInputs.filter((input) => input.checked).map((input) => input.value), outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), }; errorBox.hidden = true; - try { prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } + let prepared; + try { prepared = prepareRequest(request); } catch (error) { errorBox.textContent = error.message; errorBox.hidden = false; return; } clearResults('Computing interactions…'); byId('loading-status').textContent = 'Loading the prediction engine…'; byId('inputs').disabled = true; byId('results').setAttribute('aria-busy', 'true'); dialog.showModal(); try { const result = await runner.run(request, (message) => { byId('loading-status').textContent = message; }); - const count = results.show(result.csv, result.csvColumns); + const count = results.show(result.csv, result.csvColumns, prepared); status.textContent = count ? `${request.task === 'evaluation' ? 'Evaluation' : 'Prediction'} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && Number(request.parameters.outNumber) === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + completedRun = { request: prepared, result }; showFiles(result.files); byId('diagnostic-text').textContent = result.diagnostics; byId('diagnostics').hidden = !result.diagnostics; } catch (error) { if (error.name === 'AbortError') status.textContent = 'Computation aborted. You can edit the sequences and try again.'; @@ -152,8 +178,26 @@ form.addEventListener('submit', async (event) => { byId('inputs').disabled = false; updateAvailability(); byId('results').setAttribute('aria-busy', 'false'); dialog.close(); byId('compute').focus(); } }); +activateGroup('sequence-input'); resetParameters(); +const buildInfo = fetch(new URL('./build-info.txt', import.meta.url)).then((response) => { + if (!response.ok) throw new Error('Missing build provenance'); return response.text(); +}); +// Keep the rejection handled even if no archive is requested. +buildInfo.catch(() => {}); fetch(new URL('./build-info.json', import.meta.url)).then((response) => { if (!response.ok) throw new Error('Missing build provenance'); return response.json(); }).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } + +byId('download-zip').addEventListener('click', async () => { + const run = completedRun; if (!run) return; + const button = byId('download-zip'); button.disabled = true; + try { + const provenance = await buildInfo; + if (completedRun !== run) return; + downloadBlob('intarna-results.zip', new Blob([createArchive(run.request, run.result, provenance)], { type: 'application/zip' })); + } catch (error) { + errorBox.textContent = `ZIP download failed: ${error.message}. Reload the staged interface with its compiled version files.`; errorBox.hidden = false; + } finally { button.disabled = false; } +}); diff --git a/webinterface/archive.js b/webinterface/archive.js new file mode 100644 index 00000000..63a510c9 --- /dev/null +++ b/webinterface/archive.js @@ -0,0 +1,81 @@ +import { makeArguments } from './protocol.js'; +import { projectCsv } from './results.js'; + +const encoder = new TextEncoder(); +const crcTable = Uint32Array.from({ length: 256 }, (_, value) => { + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0); + return value >>> 0; +}); +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +// A standard, uncompressed ZIP: local generation needs no CDN or dependency. +// Fixed timestamps and UTF-8 names make the same completed run reproducible. +export function zipFiles(files) { + const local = [], central = [], names = new Set(); + let offset = 0, directorySize = 0; + if (files.length > 65535) throw new Error('Too many files for a ZIP archive.'); + for (const file of files) { + if (!file.name || /[/\\\x00]/.test(file.name) || ['.', '..'].includes(file.name) || names.has(file.name)) throw new Error('Invalid or duplicate archive filename.'); + names.add(file.name); + const name = encoder.encode(file.name), data = encoder.encode(file.text), crc = crc32(data); + if (name.length > 65535 || offset + name.length + data.length + 30 > 0xffffffff) throw new Error('ZIP archive is too large.'); + const header = new Uint8Array(30), view = new DataView(header.buffer); + view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); + view.setUint16(6, 0x800, true); view.setUint16(12, 33, true); // 1980-01-01 + view.setUint32(14, crc, true); view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); + view.setUint16(26, name.length, true); + local.push(header, name, data); + const entry = new Uint8Array(46), record = new DataView(entry.buffer); + record.setUint32(0, 0x02014b50, true); record.setUint16(4, 20, true); record.setUint16(6, 20, true); + record.setUint16(8, 0x800, true); record.setUint16(14, 33, true); + record.setUint32(16, crc, true); record.setUint32(20, data.length, true); record.setUint32(24, data.length, true); + record.setUint16(28, name.length, true); record.setUint32(42, offset, true); + central.push(entry, name); directorySize += entry.length + name.length; + offset += header.length + name.length + data.length; + } + const end = new Uint8Array(22), view = new DataView(end.buffer); + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, files.length, true); view.setUint16(10, files.length, true); + view.setUint32(12, directorySize, true); view.setUint32(16, offset, true); + const zip = new Uint8Array(offset + directorySize + end.length); + let position = 0; + for (const bytes of [...local, ...central, end]) { zip.set(bytes, position); position += bytes.length; } + return zip; +} + +export function archiveFiles(request, result, provenance) { + if (!provenance?.trim()) throw new Error('Compiled version information is unavailable'); + const versionComments = provenance.trimEnd().split(/\r?\n/).map((line) => `# ${line}`).join('\n'); + const reports = [['normal', 'N'], ['detailed', 'D'], ['ensemble', 'E']].filter(([key]) => request.outputs.includes(key)); + const config = (format, filename) => { + // Reuse the worker's effective arguments, replacing only MEMFS paths and + // internal visualization columns. Optional data are shipped as text files. + const args = makeArguments(request, format).filter((arg) => !['--qIdxPos0=1', '--tIdxPos0=1'].includes(arg)); + const settings = args.map((arg) => { + if (arg.startsWith('--outCsvCols=')) return `outCsvCols = ${request.csvColumns.join(',')}`; + return arg.slice(2).replace('=', ' = ').replace(/\/(query\.fa|target\.fa|[qt](?:Shape|AccFile)\.txt)/g, '$1') + .replace('/results.csv', 'intarna-interactions.csv').replace('/report.txt', `intarna-${filename}.txt`).replace('/output/', ''); + }); + return `${versionComments}\n# Run from the extracted archive directory with these compiled library versions.\n# Reproduce: IntaRNA --parameterFile=${filename}.cfg\n# Only non-default analysis settings are listed; input/output routing is explicit.\n${settings.join('\n')}\n`; + }; + const parameters = config('C', 'parameters') + reports.map(([key]) => `# Additional report: IntaRNA --parameterFile=${key}.cfg\n`).join(''); + const files = [ + { name: 'query.fa', text: request.query.fasta }, { name: 'target.fa', text: request.target.fasta }, + { name: 'parameters.cfg', text: parameters }, { name: 'build-info.txt', text: provenance }, + { name: 'intarna-interactions.csv', text: projectCsv(result.csv, request.csvColumns) }, + ...result.files, + ...reports.map(([key, format]) => ({ name: `${key}.cfg`, text: config(format, key) })), + ]; + for (const [key, value] of Object.entries(request.parameters)) { + if (/^(q|t)(Shape|AccFile)$/.test(key) && value) files.push({ name: `${key}.txt`, text: value + '\n' }); + } + return files; +} + +export function createArchive(request, result, provenance) { + return zipFiles(archiveFiles(request, result, provenance)); +} diff --git a/webinterface/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index 4fae4a7c..dc09f76c 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -17,23 +17,52 @@ node scripts/wasm/smoke.mjs node scripts/wasm/smoke-protocol.mjs ``` -`WASM_BUILD_DIR` changes the build directory; `JOBS` defaults to 2. Source -archives are downloaded into `build-wasm/downloads` and verified by pinned -SHA-256 hashes on every invocation. The build directory is ignored. Delete -`build-wasm/deps` and `build-wasm/sources` for a fresh dependency build after -changing dependency versions or compiler flags. Do not reuse this prefix with a -different SDK. Native libraries are never accepted from system pkg-config paths. +`WASM_BUILD_DIR` changes the build directory; `JOBS` defaults to 2. Local builds +pin ViennaRNA 2.7.2 and its official GitHub release asset SHA-256 in +`scripts/wasm/vienna-release.json`. On every run, the GitHub workflow resolves +the newest stable **2.x** release (at least 2.7.2), excludes drafts/prereleases +and major 3, and uses the asset's published SHA-256. Selection compares numeric +versions across all API pages, rather than using GitHub's `/latest` endpoint. +Missing release assets, digests or failed API requests stop the build. + +To use that same selection locally (optionally set `GITHUB_TOKEN` for API limits): + +```sh +python3 scripts/wasm/vienna-release.py --latest > /tmp/vienna-release.json +WASM_VIENNA_RELEASE=/tmp/vienna-release.json bash scripts/wasm/build.sh +``` + +Keep the resolved manifest to reproduce that exact release later with +`WASM_VIENNA_RELEASE`. `build-info.json` and `build-info.txt` record the selected +archive URL and checksum as well as the actual compiled library version. + +Source archives are downloaded into `build-wasm/downloads` and verified by the +selected SHA-256 hashes on every invocation. Use the official GitHub release +asset, rather than an autogenerated source archive; the university endpoint +returned an HTTP 200 text response instead of the archive in PR #291's build. +`scripts/wasm/download.sh` removes invalid cached archives and checks new +downloads before moving them from `.part` into the cache. Failed transfers and +checksum mismatches leave no cached archive or partial file, so a subsequent +build can retry. Run `python3 scripts/wasm/test-downloads.py` to check download, +cache recovery and failure handling against a local HTTP server, and +`python3 scripts/wasm/test-vienna-release.py` for release selection and manifest +validation, without an SDK or external network access. The build directory is +ignored. ViennaRNA sources, its installation prefix and IntaRNA objects are +separated by the selected version and checksum, so upgrades cannot reuse an +older RNAlib or IntaRNA object cache (including the old 2.7.0 prefix). zlib and +Boost use a separate `build-wasm/deps/common` prefix, so legacy ViennaRNA headers +cannot override the selected release through Boost's include path. Their caches +remain reusable across ViennaRNA upgrades. Delete the build directory for a fresh build +after changing compiler flags or SDKs. Native libraries are never accepted +from system pkg-config paths. The script supplies absolute source and generated-header include paths for the out-of-tree IntaRNA build, and uses generated files from the build directory. ViennaRNA also needs an explicit source include path when its no-SVM build -omits the target's normal include flags. ViennaRNA 2.7.0 bundles dlib 19.24, whose Unicode -`uint32` string requires a generic `std::char_traits` removed in modern libc++. -The script backports [dlib's upstream `char32_t` typedef](https://github.com/davisking/dlib/blob/master/dlib/unicode/unicode.h) -in the downloaded source only, adds its matching serialization overload and -supplies a missing dependent template argument rejected by current Clang. -`scripts/wasm/patch-vienna.py` checks the expected source before each replacement. -It does not change the RNA algorithms. +omits the target's normal include flags. ViennaRNA 2.7.2 bundles dlib 20.0, +which already supports modern libc++; no downloaded sources are patched. +Staging takes the ViennaRNA, dlib and Naview notices from the source archive +identified by the compiled provenance, and displays their selected versions. The legacy Boost and zlib configure macros declare dummy test functions with incorrect signatures (`exit()` and `inflateEnd()`), which wasm-ld correctly @@ -49,7 +78,7 @@ RNAlib link flags because the upstream `.pc` includes native-only linker flags. - Boost 1.87.0: headers plus static filesystem, program_options, regex, iostreams, serialization and system libraries, built with Boost.Build's Emscripten toolset. Optional bzip2, lzma and zstd backends are disabled. -- ViennaRNA 2.7.0: static RNAlib, without OpenMP, pthreads, SIMD, external +- ViennaRNA 2.7.2 locally, newest stable 2.x in CI: static RNAlib, without OpenMP, pthreads, SIMD, external solvers, bindings, documentation or companion applications. Compiling only RNAlib avoids executing cross-compiled helper applications on the host. - IntaRNA: C++23, bundled Kokkos mdspan, standard CLI, default energy models diff --git a/webinterface/docs/step2-worker-architecture.md b/webinterface/docs/step2-worker-architecture.md index 8a2f8090..9617fbbd 100644 --- a/webinterface/docs/step2-worker-architecture.md +++ b/webinterface/docs/step2-worker-architecture.md @@ -24,8 +24,11 @@ exits and JS/runtime failures settle as errors, without partial successful files Both success and failure terminate the worker. Abort uses `Worker.terminate()` on the main thread and immediately rejects with `AbortError`, including during synchronous C++ execution. Stale messages are ignored and the next task starts -fresh. Inputs are never uploaded or persisted. No backend, shared memory or -cross-origin isolation is needed. A single WASM instance can grow up to 1 GiB. +fresh. Computation never uploads or persists inputs. Selecting a result to +visualize it loads the external vaRRI viewer with that pair and its structure +in the URL. The iframe is cleared on input edits; no third-party viewer is +loaded before selection. No backend, shared memory or cross-origin isolation +is needed for computation. A single WASM instance can grow up to 1 GiB. Run `npm test --prefix webinterface`, then the native-reference WASM smoke tests and Chromium/Firefox suites described in step 4. diff --git a/webinterface/docs/step3-frontend-ui.md b/webinterface/docs/step3-frontend-ui.md index 49289403..c16dcbde 100644 --- a/webinterface/docs/step3-frontend-ui.md +++ b/webinterface/docs/step3-frontend-ui.md @@ -13,24 +13,31 @@ The layout puts the scientific task first, then mutually exclusive parameter groups, followed by the compute button, results, additional files and versions. All working content is left aligned; two equally sized strand editors collapse into one column on mobile. The shared institutional header is the strongest -visual element. Parameter borders and blue summaries identify functional groups. +visual element. The wrapping tab bar identifies parameter groups. Numeric inputs +are 9 rem wide within a responsive grid of compact controls; text/data fields +retain useful width. +The institutional link reads “Bioinformatics - University of Freiburg” and opens +the group website. Footer and table notes use the available width, wrapping only +when the viewport requires it. ```text Group logo IntaRNA / RNA–RNA interaction analysis University logo -RNA–RNA interactions Sequences stay on your device +RNA–RNA interactions Computations stay on your device Task: Prediction / Evaluation Load example -[ Sequence input: query + target, start indices, evaluation structures ] -[ Query ] [ Target ] [ Seed ] [ Helix ] [ SHAPE ] [ Interaction/energy ] -[ Output: filters, CSV columns, additional files ] [ General ] +[ Sequence input | Query | Target | Seed | Helix | SHAPE | Energy | Output | General ] +[ Active panel: e.g. query + target, start indices, evaluation structures ] Reset parameters Compute -Interactions / search / show-hide columns / CSV download +Interactions / search / show-hide columns / CSV + ZIP download +Selected interaction / embedded vaRRI Additional output files / diagnostics / compiled versions ``` -Every CLI group has a menu. `details[name]` plus a toggle fallback keep at most -one parameter menu open. Sequence input starts open. Prediction-only controls -are disabled/hidden during evaluation. Changing task resets parameters to their -CLI defaults while preserving sequence text; Load example also restores the +Every CLI group has a tab with one visible panel. Sequence input is selected +initially. Tabs expose `aria-selected`, `aria-controls` and roving focus; Left/Right +move between available tabs, and Home/End select the first/last. Tab names wrap +on narrow screens. Prediction-only tabs and controls are disabled/hidden during +evaluation; if the selected tab becomes unavailable, Sequence input is selected. +Changing task resets parameters to their CLI defaults while preserving sequence text; Load example also restores the complementary pair. The native default output count is one; browser users can request 0–100 interactions per pair. Zero enables accessibility-only output. diff --git a/webinterface/docs/step4-results-and-deployment.md b/webinterface/docs/step4-results-and-deployment.md index 2c8ffe99..8d2e3aaf 100644 --- a/webinterface/docs/step4-results-and-deployment.md +++ b/webinterface/docs/step4-results-and-deployment.md @@ -3,8 +3,8 @@ ## Results contract The CSV checklist covers all `OutputHandlerCsv` column IDs. The worker requests -those columns plus internal target/query IDs, coordinates and energy needed for -pair grouping and result validation. Downloads project the untouched native CSV +those columns plus internal target/query IDs, coordinates, energy and `hybridDP` +needed for pair grouping, result validation and visualization. Downloads project the untouched native CSV cells onto the user's selected columns, in selection order, retaining every row regardless of table filtering/visibility. No computed mfe difference is inserted into native CSV. The selected-column catalog is checked against the C++ mapping. @@ -27,6 +27,59 @@ and diagnostics render with `textContent`. Selected extra files appear below the table with bounded text previews and complete downloads, including SVG source. Empty output is successful and still permits CSV/accessibility downloads. +## Selected interaction in vaRRI + +Each row has a keyboard-accessible View button; clicking a row also selects it. +Selection survives table sorting, filtering and visibility changes. Input edits, +new runs, errors and cancellation clear the viewer and archive snapshot. +`visualization.js` uses native seq1 = target, seq2 = query and maps the native +CSV identifiers to the completed run's normalized FASTA records (including +semicolon-to-underscore ID conversion). It pads the interaction dot-bracket +structure with unpaired positions to the full input lengths, using each start +index and the native negative-to-positive transition that skips zero. + +The URL follows [vaRRI's embedding API](https://github.com/BackofenLab/vaRRI/blob/main-v2/docs/sharing-and-input.md): +`sequence`, `structure`, `seqName1/2`, `startIndex1/2`, and +`showRenderingOnly=true`. Names use the main-v2 API; older deployed vaRRI versions +may retain generic strand names. A separate Open in vaRRI link opens the full +viewer. The local status always identifies the selected pair and coordinates. +Zero-based CLI start indices remain accepted for computation/export, but vaRRI +requires nonzero start indices; choosing View for such a run explains this limit. + +No external viewer loads until a row is selected. The interface explains that +selection shares that pair and structure through vaRRI's URL. Computation and +ZIP generation remain local. CSP permits frames only at the vaRRI deployment; +the iframe sends no referrer. Viewer availability depends on that external site; +the full-viewer link permits retry, and scientific outputs remain downloadable. + +## Reproducible ZIP download + +Download ZIP sits beside Download CSV. `archive.js` creates a standard UTF-8, +uncompressed ZIP with CRC-32 checksums locally, without a library or CDN. It uses +the completed request and result, independent of table sorting/filtering and +column visibility. Editing inputs discards the completed snapshot. + +The archive contains: + +- `query.fa` and `target.fa`: the exact normalized FASTA consumed by the worker. +- `parameters.cfg`: native parameter-file syntax, non-default effective analysis + settings, explicit input/output routing and the selected CSV columns. Comments + include the exact compiled version/provenance text and the replay command + `IntaRNA --parameterFile=parameters.cfg` from the extracted directory. +- `build-info.txt`: original compiled engine/library versions and asset hashes. +- `intarna-interactions.csv`: all results with the selected CSV columns, identical + to Download CSV even if the table has been sorted, filtered or columns hidden. +- Every generated additional output, with its original filename and contents. +- Local SHAPE/accessibility input files when supplied, referenced by relative paths. +- Separate `normal.cfg`, `detailed.cfg` or `ensemble.cfg` when those reports were + requested, with their replay commands also listed in `parameters.cfg`. Each + report requires a separate native invocation, matching worker behavior. + +Replay with the recorded IntaRNA and library versions. Export translates MEMFS +paths to portable relative paths and omits defaults using the worker's own +argument builder. ZIP download reports a clear error if compiled version text +is unavailable, rather than claiming incomplete provenance. + ## Packaging and deployment ```sh @@ -137,3 +190,26 @@ claim a live Pages deployment. - Staging preserves compiled metadata byte-for-byte; a deliberately changed copied JS engine is rejected by its asset digest check. Shell syntax, workflow YAML, Python syntax and `git diff --check` pass. + +### PR #287 follow-up verification, 2026-10-22 + +- All 12 Node test groups pass, including coordinate/strand mapping, non-default + export settings, auxiliary inputs and report configurations. +- `node scripts/wasm/smoke.mjs` and `node scripts/wasm/smoke-protocol.mjs` pass + using the previously compiled Emscripten 4.0.23 engine (revision `fc62a23`). + The latter executes exported parameter files in fresh engines with only + archived inputs and verifies byte-identical CSV, per-record outputs, + probability SVGs and ASCII/detailed/ensemble reports. Cases include signed + multi-FASTA, changed energy/seed settings, SHAPE and accessibility imports. +- Chromium 156 and Firefox 157 with Playwright 1.64.0 pass the real-WASM browser + suite at a nested URL. It covers tab keyboard behavior, selection/encoded + viewer URLs, Python `zipfile` validation of downloaded ZIP contents, hidden + CSV columns, evaluation reports, empty results and the existing lifecycle + checks. Only the third-party iframe response is stubbed in CI; computation + runs the real engine. Desktop/parameter/mobile screenshots were reviewed. +- A separate live Chromium check renders RNA base circles in the actual hosted + vaRRI iframe. An independent Python `zipfile` check verifies CRCs, Unicode + filenames/data and empty files in the ZIP writer's output. +- Native `make tests -j2` passes all eight API/CLI groups (462,689 assertions in + 119 API cases). No C++ or compiled engine changes were needed for this follow-up. +- Staging, shell/Python syntax and `git diff --check` pass. diff --git a/webinterface/index.html b/webinterface/index.html index 318c101f..e7d56a5d 100644 --- a/webinterface/index.html +++ b/webinterface/index.html @@ -4,7 +4,7 @@ - + IntaRNA · RNA–RNA interactions @@ -15,12 +15,12 @@

    RNA–RNA interactions

    Predict interactions or evaluate the energy of structures you provide.

    -

    Sequences stay on your device

    +

    Computations stay on your device

    @@ -29,8 +29,8 @@

    Predict interactions for all query–target sequence pairs.

    -
    - Sequence input +
    +
    5′ to 3′
    @@ -47,7 +47,7 @@

    Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 20 records per field and 2–2,000 nt per record; total query × target length across selected pairs ≤ 1,000,000. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

    -
    +
    @@ -56,11 +56,17 @@

    Interactions

    Enter your sequences or try the example, then compute.

    diff --git a/webinterface/protocol.js b/webinterface/protocol.js index cfc9451e..d70c0a40 100644 --- a/webinterface/protocol.js +++ b/webinterface/protocol.js @@ -222,7 +222,7 @@ export function makeArguments(request, format = 'C') { } if (request.rri) args.push(`--rri=${request.rri}`); if (format === 'C') { - args.push(`--outCsvCols=${[...new Set([...request.csvColumns, ...CONTEXT_COLUMNS])].join(',')}`); + args.push(`--outCsvCols=${[...new Set([...request.csvColumns, ...CONTEXT_COLUMNS, 'hybridDP'])].join(',')}`); for (const key of request.outputs.filter((key) => !['normal', 'detailed', 'ensemble'].includes(key))) { const spots = key === 'spotProb' && request.parameters.outSpotProbSpots ? `${request.parameters.outSpotProbSpots}:` : ''; args.push(`--out=${key}:${spots}/output/${key}${key === 'bpsvg' ? '.svg' : '.csv'}`); diff --git a/webinterface/results.js b/webinterface/results.js index 1c4b5d41..aeceb38a 100644 --- a/webinterface/results.js +++ b/webinterface/results.js @@ -1,5 +1,6 @@ import { CSV_CATALOG } from './columns.js'; import { CONTEXT_COLUMNS } from './protocol.js'; +import { InteractionVisualization } from './visualization.js'; export const COLUMNS = Object.freeze([...CSV_CATALOG, { key: 'deltaE', label: 'mfe Δ (kcal/mol)', numeric: true }]); const catalog = new Map(COLUMNS.map((column) => [column.key, column])); @@ -53,7 +54,10 @@ export function projectCsv(csv, columns) { return lines.map((line) => { const cells = line.split(';'); return indices.map((index) => cells[index]).join(';'); }).join('\n') + '\n'; } export function downloadText(name, text) { - const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + downloadBlob(name, new Blob([text], { type: 'text/plain;charset=utf-8' })); +} +export function downloadBlob(name, blob) { + const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); @@ -61,6 +65,7 @@ export function downloadText(name, text) { export class ResultsView { constructor(root) { this.root = root; this.rows = []; this.key = 'E'; this.direction = 1; + this.visualization = new InteractionVisualization(root); this.search = root.querySelector('#search'); this.table = root.querySelector('table'); this.body = this.table.querySelector('tbody'); @@ -73,9 +78,11 @@ export class ResultsView { } clear() { this.rows = []; this.csv = ''; this.body.replaceChildren(); this.root.hidden = true; + this.selected = null; this.visualization.clear(); } - show(csv, columns = csv.trim().split('\n')[0].split(';')) { + show(csv, columns = csv.trim().split('\n')[0].split(';'), request) { this.rows = parseResults(csv); this.csv = projectCsv(csv, columns); + this.request = request; this.selected = null; this.visualization.clear(); this.columns = columns.map((key) => catalog.get(key)); if (columns.includes('E')) this.columns.push(catalog.get('deltaE')); this.visible = new Set(this.columns.map((c) => c.key)); @@ -96,6 +103,8 @@ export class ResultsView { const rows = selectRows(this.rows, this.search.value, this.key, this.direction); const columns = this.columns.filter((column) => this.visible.has(column.key)); this.header.replaceChildren(); + const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.textContent = 'vaRRI'; + this.header.append(viewHeading); for (const column of columns) { const cell = document.createElement('th'), button = document.createElement('button'); cell.scope = 'col'; button.type = 'button'; button.textContent = column.label; @@ -108,6 +117,19 @@ export class ResultsView { this.body.replaceChildren(); for (const row of rows) { const tr = document.createElement('tr'); + tr.classList.toggle('selected', row === this.selected); + const action = document.createElement('td'), view = document.createElement('button'); + view.type = 'button'; view.textContent = 'View'; view.className = 'view-interaction'; + view.setAttribute('aria-label', `Visualize ${row.id1} ${row.start1}–${row.end1} with ${row.id2} ${row.start2}–${row.end2}`); + view.setAttribute('aria-pressed', String(row === this.selected)); + tr.addEventListener('click', () => { + this.selected = row; this.visualization.show(row, this.request); + for (const other of this.body.children) { + other.classList.toggle('selected', other === tr); + other.querySelector('.view-interaction').setAttribute('aria-pressed', String(other === tr)); + } + }); + action.append(view); tr.append(action); for (const column of columns) { const cell = document.createElement('td'); cell.textContent = ['E', 'deltaE'].includes(column.key) ? Number(row[column.key]).toFixed(2) : String(row[column.key]); diff --git a/webinterface/styles.css b/webinterface/styles.css index 30e7a8a9..b776078c 100644 --- a/webinterface/styles.css +++ b/webinterface/styles.css @@ -182,6 +182,12 @@ h3 { .university-link { margin-left:auto; + display:flex; + align-items:center; + gap:12px; + font-size:.85rem; + line-height:1.35; + text-align:right; } main { @@ -238,36 +244,56 @@ fieldset { color:var(--muted); } -.parameter-group { +#parameter-groups { border:1px solid var(--border); background:#fbfdff; border-radius:8px; - margin-bottom:8px; } -.parameter-group > summary { - padding:.75rem 1rem; +#parameter-tabs { + display:flex; + flex-wrap:wrap; + gap:4px; + padding:8px; + border-bottom:1px solid var(--border); + background:var(--brand-blue-soft); + border-radius:7px 7px 0 0; +} + +[role=tab] { + border-color:transparent; + background:transparent; color:var(--brand-blue-dark); - font-weight:700; - user-select:none; + padding:.55rem .75rem; + font-size:.85rem; } -.parameter-group[open] > summary { - background:var(--brand-blue-soft); - border-bottom:1px solid var(--border); - border-radius:7px 7px 0 0; +[role=tab][aria-selected=true] { + background:var(--brand-blue); + color:white; +} + +[role=tab]:hover { + border-color:var(--brand-blue); } .group-body { padding:18px; } -.sequence-grid,.field-grid { +.sequence-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 24px; } +.field-grid { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); + gap:20px 24px; + align-items:start; +} + .strand-heading,.sequence-meta { display:flex; align-items:center; @@ -323,6 +349,10 @@ fieldset { font-size:.9rem; } +.field input[type=number] { + width:9rem; +} + .field-hint { margin:0; } @@ -393,6 +423,12 @@ fieldset { margin:18px 0 12px; } +.download-actions { + display:flex; + flex-wrap:wrap; + gap:8px; +} + .table-tools label { display:flex; gap:8px; @@ -461,6 +497,53 @@ td.number { .table-note { margin:12px 0; + max-width:none; +} + +.view-interaction { + padding:.25rem .6rem; + color:var(--brand-blue-dark); + background:var(--surface); +} + +tbody tr { + cursor:pointer; +} + +tbody tr:hover,tbody tr.selected { + background:var(--brand-blue-soft); +} + +.view-interaction[aria-pressed=true] { + background:var(--brand-blue); + color:white; +} + +#visualization { + margin-top:20px; +} + +.visualization-heading { + display:flex; + align-items:baseline; + flex-wrap:wrap; + gap:16px; +} + +#varri-frame { + display:block; + width:100%; + height:600px; + margin-top:12px; + border:1px solid var(--border); + border-radius:5px; + background:white; +} + +#visualization-status { + max-width:none; + overflow-wrap:anywhere; + font-size:.9rem; } #output-files { @@ -495,6 +578,7 @@ footer { footer p { margin-bottom:8px; + max-width:none; } .error { @@ -571,6 +655,10 @@ dialog::backdrop { .uni-logo { height:36px; } + .university-link { + font-size:.75rem; + gap:8px; + } .brand { font-size:1.2rem; } @@ -590,9 +678,12 @@ dialog::backdrop { .local-note { margin-top:12px; } - .sequence-grid,.field-grid { + .sequence-grid { grid-template-columns:1fr; } + .field-grid { + grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); + } .results { padding:14px; } diff --git a/webinterface/tests/export.test.mjs b/webinterface/tests/export.test.mjs new file mode 100644 index 00000000..60d83edd --- /dev/null +++ b/webinterface/tests/export.test.mjs @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULTS, prepareRequest } from '../protocol.js'; +import { archiveFiles } from '../archive.js'; +import { visualizationUrl } from '../visualization.js'; + +test('visualization retains full strand orientation, bulges, names and indices crossing zero', () => { + const request = prepareRequest({ ...DEFAULTS, query: '>other\nGGGAGGAGGG\n>q;β\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5 }); + const url = new URL(visualizationUrl({ id1: 'target', id2: 'q_β', start1: -3, end1: 3, start2: -2, end2: 4, E: -1, hybridDP: '((..((&)).)).' }, request)); + assert.equal(url.origin + url.pathname, 'https://backofenlab.github.io/vaRRI/'); + assert.equal(url.searchParams.get('sequence'), 'CCCUCCUCCC&GGGAGGAGGG'); + assert.equal(url.searchParams.get('structure'), '..((..((..&.)).))....'); + assert.equal(url.searchParams.get('seqName2'), 'q_β'); + assert.equal(url.searchParams.get('startIndex1'), '-5'); + assert.equal(url.searchParams.get('startIndex2'), '-3'); + assert.equal(url.searchParams.get('showRenderingOnly'), 'true'); + assert.throws(() => visualizationUrl({ id1: 'missing', hybridDP: '(&)' }, request), /not found/); + assert.throws(() => visualizationUrl({ id1: 'target', id2: 'q_β', start1: -5, end1: -4, hybridDP: '(((&)))' }, request), /coordinates/); + assert.throws(() => visualizationUrl({ id1: 'target', hybridDP: '(&)' }, { ...request, parameters: { ...request.parameters, tIdxPos0: 0 } }), /nonzero/); +}); + +test('archive preserves effective non-default settings, selected CSV, auxiliary inputs and reports', () => { + const request = prepareRequest({ ...DEFAULTS, task: 'evaluation', rri: '1||||||||||&3||||||||||', qIdxPos0: 3, parameters: { temperature: 21, qShape: '1 0.2\n2 0.3' }, csvColumns: ['E'] }); + request.outputs = ['detailed']; + const files = archiveFiles(request, { csv: 'id1;start1;end1;id2;start2;end2;E;hybridDP\ntarget;1;10;query;3;12;-21.4;((((((((((&))))))))))\n', files: [{ name: 'intarna-detailed.txt', text: 'report' }] }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + const entries = Object.fromEntries(files.map((file) => [file.name, file.text])); + assert.equal(entries['query.fa'], '>query\nGGGAGGAGGG\n'); + assert.equal(entries['intarna-interactions.csv'], 'E\n-21.4\n'); + assert.equal(entries['qShape.txt'], '1 0.2\n2 0.3\n'); + assert.match(entries['parameters.cfg'], /# ViennaRNA 2.7.0/); + for (const line of ['personality = IntaRNAeval', 'temperature = 21', 'qIdxPos0 = 3', 'qShape = qShape.txt', 'outCsvCols = E']) assert.ok(entries['parameters.cfg'].includes(line), line); + assert.ok(!entries['parameters.cfg'].includes('tIdxPos0 =')); + assert.ok(!entries['parameters.cfg'].includes('seedBP =')); + assert.match(entries['detailed.cfg'], /out = intarna-detailed.txt/); + assert.match(entries['parameters.cfg'], /IntaRNA --parameterFile=parameters.cfg/); + assert.equal(entries['intarna-detailed.txt'], 'report'); + assert.throws(() => archiveFiles(request, { csv: '', files: [] }, ''), /version/); +}); diff --git a/webinterface/visualization.js b/webinterface/visualization.js new file mode 100644 index 00000000..46837c57 --- /dev/null +++ b/webinterface/visualization.js @@ -0,0 +1,48 @@ +import { sequenceOffset } from './protocol.js'; + +export function visualizationUrl(row, request) { + // Native seq1 is target and seq2 is query; both structures run 5′ to 3′. + const structures = row.hybridDP?.split('&'); + if (structures?.length !== 2) throw new Error('The interaction has no dot-bracket structure.'); + const sequences = [], fullStructures = [], starts = []; + for (const [index, strand] of ['target', 'query'].entries()) { + const number = index + 1, start = request.parameters[index ? 'qIdxPos0' : 'tIdxPos0']; + const record = request[strand].records.find((record) => record.id.replaceAll(';', '_') === row[`id${number}`]); + if (!record) throw new Error('The interaction sequence was not found in this run.'); + if (start === 0) throw new Error('vaRRI requires nonzero sequence start indices. Choose a nonzero start index to visualize this pair.'); + const offset = sequenceOffset(row[`start${number}`], start), structure = structures[index]; + const end = sequenceOffset(row[`end${number}`], start); + if (offset < 0 || end >= record.sequence.length || structure.length !== end - offset + 1 || /[^.()]/.test(structure)) throw new Error('The interaction structure does not match its sequence coordinates.'); + sequences.push(record.sequence); starts.push(start); + fullStructures.push('.'.repeat(offset) + structure + '.'.repeat(record.sequence.length - end - 1)); + } + const url = new URL('https://backofenlab.github.io/vaRRI/'); + url.search = new URLSearchParams({ + sequence: sequences.join('&'), structure: fullStructures.join('&'), + seqName1: row.id1, seqName2: row.id2, startIndex1: starts[0], startIndex2: starts[1], + showRenderingOnly: 'true', forceLayout: 'false', forceLayoutLinearRRI: 'true', + }); + return url.href; +} + +export class InteractionVisualization { + constructor(root) { + this.frame = root.querySelector('#varri-frame'); this.link = root.querySelector('#varri-link'); + this.status = root.querySelector('#visualization-status'); + } + clear() { + this.frame.removeAttribute('src'); this.frame.hidden = true; + this.link.removeAttribute('href'); this.link.hidden = true; + this.status.textContent = 'Select View on an interaction to visualize it.'; + } + show(row, request) { + this.clear(); + try { + const url = visualizationUrl(row, request); + this.frame.src = url; this.frame.hidden = false; + const fullUrl = new URL(url); fullUrl.searchParams.delete('showRenderingOnly'); + this.link.href = fullUrl.href; this.link.hidden = false; + this.status.textContent = `${row.id1} (${row.start1}–${row.end1}) & ${row.id2} (${row.start2}–${row.end2}), ${row.E.toFixed(2)} kcal/mol. If the viewer is unavailable, use Open in vaRRI to retry.`; + } catch (error) { this.status.textContent = error.message; } + } +}