From 44c2691ad0d79662bfbabc3ff1bebb13fdb99acd Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 12:03:08 +0200 Subject: [PATCH 1/4] feat(web): add vaRRI visualization and reproducible ZIP exports --- ChangeLog | 25 ++++ README.md | 1 + scripts/wasm/smoke-protocol.mjs | 27 +++- scripts/wasm/stage.sh | 2 +- scripts/wasm/test-browser.py | 96 +++++++++++++-- webinterface/app.js | 72 ++++++++--- webinterface/archive.js | 81 ++++++++++++ .../docs/step2-worker-architecture.md | 7 +- webinterface/docs/step3-frontend-ui.md | 27 ++-- .../docs/step4-results-and-deployment.md | 80 +++++++++++- webinterface/index.html | 20 +-- webinterface/protocol.js | 2 +- webinterface/results.js | 26 +++- webinterface/styles.css | 115 ++++++++++++++++-- webinterface/tests/export.test.mjs | 38 ++++++ webinterface/visualization.js | 48 ++++++++ 16 files changed, 604 insertions(+), 63 deletions(-) create mode 100644 webinterface/archive.js create mode 100644 webinterface/tests/export.test.mjs create mode 100644 webinterface/visualization.js diff --git a/ChangeLog b/ChangeLog index 9a9be5b4..63803d54 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 Δ @@ -241,6 +245,27 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +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/smoke-protocol.mjs b/scripts/wasm/smoke-protocol.mjs index d3947aef..f168a7b1 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -4,7 +4,8 @@ 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)); async function run(input, format = 'C') { @@ -18,7 +19,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 +46,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 }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + 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..d123f2b1 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -8,7 +8,7 @@ test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" 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" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 29b19f11..3bcdadbc 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -1,6 +1,9 @@ """Exercise the staged browser interface with real WASM at a nested URL.""" import argparse from pathlib import Path +from io import BytesIO +from urllib.parse import parse_qs, urlparse +from zipfile import ZipFile from playwright.sync_api import sync_playwright, expect parser = argparse.ArgumentParser(description=__doc__) @@ -14,6 +17,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 +30,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') @@ -44,7 +59,17 @@ def predict(): 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 +86,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 +106,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 'Vienna RNA package 2.7.0' 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 +143,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 +187,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 +233,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 +250,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/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/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; } + } +} From 77eb89d64b8ac498bdda2b7504193054920659f9 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 15:06:37 +0200 Subject: [PATCH 2/4] fix(wasm): repair ViennaRNA download and cache recovery --- .github/workflows/build-wasm.yml | 2 + ChangeLog | 18 ++++ scripts/wasm/build.sh | 11 +-- scripts/wasm/download.sh | 23 ++++++ scripts/wasm/test-downloads.py | 92 +++++++++++++++++++++ webinterface/docs/step1-wasm-compilation.md | 10 ++- 6 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 scripts/wasm/download.sh create mode 100644 scripts/wasm/test-downloads.py diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index 1790b36a..c6baf0d0 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -30,6 +30,8 @@ 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 - name: Test input, worker lifecycle and CSV handling run: npm test --prefix webinterface - name: Cross-compile dependencies and IntaRNA diff --git a/ChangeLog b/ChangeLog index 63803d54..01272691 100644 --- a/ChangeLog +++ b/ChangeLog @@ -84,6 +84,10 @@ ## Technical changes and Optimizations +- 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 @@ -245,6 +249,20 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +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, diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index b6dd493d..60140119 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -12,19 +12,14 @@ for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node; do 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 +fetch https://github.com/ViennaRNA/ViennaRNA/releases/download/v2.7.0/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ + e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d # Keep pkg-config searches inside the WASM prefix, including configure probes. export PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" 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/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/webinterface/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index 4fae4a7c..06aba756 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -19,7 +19,15 @@ 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 +SHA-256 hashes on every invocation. ViennaRNA 2.7.0 uses the official GitHub +release asset, with its own pinned checksum; the university download 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, without an SDK +or external network access. 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. From 87e548c4cc97e4c2aca9ce433ea672873054364f Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 15:30:13 +0200 Subject: [PATCH 3/4] fix(wasm): build against the latest stable ViennaRNA 2.x --- .github/workflows/build-wasm.yml | 11 ++- ChangeLog | 23 +++++ scripts/wasm/build.sh | 53 +++++++----- scripts/wasm/patch-vienna.py | 25 ------ scripts/wasm/smoke-protocol.mjs | 4 +- scripts/wasm/stage.sh | 28 +++++-- scripts/wasm/test-browser.py | 11 ++- scripts/wasm/test-vienna-release.py | 90 ++++++++++++++++++++ scripts/wasm/vienna-release.json | 5 ++ scripts/wasm/vienna-release.py | 93 +++++++++++++++++++++ scripts/wasm/write-build-info.mjs | 6 +- webinterface/docs/step1-wasm-compilation.md | 53 ++++++++---- 12 files changed, 327 insertions(+), 75 deletions(-) delete mode 100644 scripts/wasm/patch-vienna.py create mode 100644 scripts/wasm/test-vienna-release.py create mode 100644 scripts/wasm/vienna-release.json create mode 100644 scripts/wasm/vienna-release.py diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index c6baf0d0..aadec7f3 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -31,9 +31,18 @@ jobs: with: python-version: '3.12' - name: Test dependency download integrity and cache recovery - run: python scripts/wasm/test-downloads.py + 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 01272691..cafc21d7 100644 --- a/ChangeLog +++ b/ChangeLog @@ -84,6 +84,10 @@ ## 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) @@ -249,6 +253,25 @@ 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 + to prevent reuse of libraries or 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 and manifest validation. + * 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; diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 60140119..1ca91ac0 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -7,7 +7,7 @@ mkdir -p "$build" build=$(cd "$build" && pwd) prefix="$build/deps" 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" @@ -18,16 +18,27 @@ 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://github.com/ViennaRNA/ViennaRNA/releases/download/v2.7.0/ViennaRNA-2.7.0.tar.gz ViennaRNA-2.7.0.tar.gz \ - e2addf33e7c0dd4fff31d8e9a46fda8e9efcde0d199f789f20eb0a13b7f7135d +# 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="$prefix/$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" @@ -47,27 +58,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" @@ -78,15 +88,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" @@ -96,5 +106,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/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 f168a7b1..ee27ad8b 100644 --- a/scripts/wasm/smoke-protocol.mjs +++ b/scripts/wasm/smoke-protocol.mjs @@ -1,5 +1,6 @@ // 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'; @@ -8,6 +9,7 @@ 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 = []; @@ -54,7 +56,7 @@ async function replayArchive(input) { 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 }, 'IntaRNA 3.4.1\nViennaRNA 2.7.0\n'); + 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) }); diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index d123f2b1..20367c2e 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -6,6 +6,24 @@ 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 archive.js visualization.js; do @@ -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 3bcdadbc..079fbdf5 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -2,7 +2,8 @@ import argparse from pathlib import Path from io import BytesIO -from urllib.parse import parse_qs, urlparse +import re +from urllib.parse import parse_qs, urljoin, urlparse from zipfile import ZipFile from playwright.sync_api import sync_playwright, expect @@ -53,7 +54,11 @@ 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']: @@ -122,7 +127,7 @@ def predict(): assert archive['intarna-interactions.csv'] == csv assert archive['query.fa'] == '>query\nGGGAGGAGGG\n' assert '# IntaRNA 3.4.1' in archive['parameters.cfg'] - assert 'Vienna RNA package 2.7.0' 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'] diff --git a/scripts/wasm/test-vienna-release.py b/scripts/wasm/test-vienna-release.py new file mode 100644 index 00000000..7357a88e --- /dev/null +++ b/scripts/wasm/test-vienna-release.py @@ -0,0 +1,90 @@ +#!/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, "") + + +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/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index 06aba756..8135c2f5 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -17,31 +17,50 @@ 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. ViennaRNA 2.7.0 uses the official GitHub -release asset, with its own pinned checksum; the university download endpoint +`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, without an SDK -or external network access. 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. +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 caches remain reusable. 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 @@ -57,7 +76,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 From d44d7a6ca00a519563aebb688f98194d66960ff3 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 22 Oct 2026 15:35:38 +0200 Subject: [PATCH 4/4] fix(wasm): isolate legacy ViennaRNA headers from Boost includes --- ChangeLog | 6 +++-- scripts/wasm/build.sh | 6 +++-- scripts/wasm/test-vienna-release.py | 30 +++++++++++++++++++++ webinterface/docs/step1-wasm-compilation.md | 4 ++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/ChangeLog b/ChangeLog index cafc21d7..02b0b5f7 100644 --- a/ChangeLog +++ b/ChangeLog @@ -259,7 +259,8 @@ energies, restricted partition sums, and trackers. 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 - to prevent reuse of libraries or headers from another release (PR #292). + 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} @@ -267,7 +268,8 @@ energies, restricted partition sums, and trackers. 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 and manifest validation. + 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. diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 1ca91ac0..10499d68 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -5,7 +5,9 @@ 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 python3; do command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } @@ -28,7 +30,7 @@ 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="$prefix/$vienna_id" +vienna_prefix="$build/deps/$vienna_id" vienna_sources="$build/sources/$vienna_id" intarna_build="$build/intarna-$vienna_id" diff --git a/scripts/wasm/test-vienna-release.py b/scripts/wasm/test-vienna-release.py index 7357a88e..2bf50995 100644 --- a/scripts/wasm/test-vienna-release.py +++ b/scripts/wasm/test-vienna-release.py @@ -85,6 +85,36 @@ def test_invalid_pin_fails_without_emitting_shell_fields(self): 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/webinterface/docs/step1-wasm-compilation.md b/webinterface/docs/step1-wasm-compilation.md index 8135c2f5..dc09f76c 100644 --- a/webinterface/docs/step1-wasm-compilation.md +++ b/webinterface/docs/step1-wasm-compilation.md @@ -50,7 +50,9 @@ 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 caches remain reusable. Delete the build directory for a fresh build +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.