| {where} | ')
+ if not rows or any(not re.search(r']*>[^<]+ | $', r) for r in rows) \
+ or not any(r.endswith(link) for r in rows):
+ bad.append(f"{name}: a row whose last cell is not a viewer link to {where}")
+ if render_report.VIEWER_NOTE.strip() not in after.split(' 100000, igv)
+ pin = igv_pin()
+ igv = f"{gd}/tools/igv.js-{pin['IGV_JS_VERSION']}/igv.min.js"
+ check("owngenome-view.sh: the igv.js pinned in versions.env is downloaded into GENOME_DIR/tools, with its sha256",
+ os.path.isfile(igv) and os.path.getsize(igv) > 100000
+ and hashlib.sha256(open(igv, "rb").read()).hexdigest() == pin["IGV_JS_SHA256"], (igv, pin))
+ check("owngenome-view.sh: it sets no igv.js pin of its own (versions.env is the one place)",
+ not re.search(r"^\s*IGV_JS_(VERSION|SHA256)=", open(f"{REPO}/scripts/owngenome-view.sh").read(), re.M))
ip = other_address()
if ip:
try:
@@ -336,32 +442,29 @@ def main():
check("browser: igv.js read the reference and the VCF",
"synthetic.fasta" in served and "S.vcf.gz" in served, served[-1500:])
- # 5. the report's links
- import collect_summary
+ # 5. the report's links: a viewer link in the locus cell of every row of every findings table
import render_report
- d = f"{work}/report/S"
- os.makedirs(f"{d}/clinvar")
- with open(f"{d}/clinvar/S_clinvar_hits.vcf", "w") as f:
- f.write("##fileformat=VCFv4.2\n#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tS\n"
- "chr1\t451\t2\tT\tG\t50\tPASS\tGENEINFO=GENEB:22;CLNSIG=Pathogenic;"
- "CLNREVSTAT=reviewed_by_expert_panel\tGT\t0/1\n")
- html = render_report.html_report(collect_summary.collect("S", d))
- check("report: the ClinVar hit links to the viewer at its position",
- 'href="http://127.0.0.1:8765/#chr1:451"' in html and "scripts/owngenome-view.sh" in html,
- [l for l in html.splitlines() if "ClinVar" in l][:5])
- summ = collect_summary.collect("S", d)
- for k in ("mito", "clinical"):
- summ["sections"][k]["state"] = "ok"
- summ["sections"]["mito"]["data"]["annotation"] = {
- "source": "synthetic", "variants": [{"pos": 3243, "ref": "A", "alt": "G"}]}
- summ["sections"]["clinical"]["data"]["acmg_sf"] = {
- "version": "v3.3",
- "clinvar_hits": [{"gene": "GENEC", "variant": "chr2:120 A>G", "genotype": "0/1", "significance": "Pathogenic"}],
- "high_impact": [{"gene": "GENED", "variant": "chr1:2000 C>T", "genotype": "0/1", "consequence": "stop_gained"}]}
- html = render_report.html_report(summ)
- want = [f'href="http://127.0.0.1:8765/#{x}"' for x in ("chrM:3243", "chr2:120", "chr1:2000")]
- check("report: the mito and secondary-findings variants link to the viewer at their positions",
- all(w in html for w in want), [l for l in html.splitlines() if "8765" in l][:5])
+ html, _ = findings_report(work)
+ bad = tables_without_links(html)
+ check("report: every row of every findings table ends with a Viewer cell that links to its locus, with the note in the card",
+ not bad, bad)
+ check("report: the note says how to start the viewer", "scripts/owngenome-view.sh" in render_report.VIEWER_NOTE)
+ # The control: with plain text in place of the link, the same check names every table.
+ real = render_report.viewer_td
+ render_report.viewer_td = lambda chrom, start, end=None: "
| "
+ try:
+ plain, _ = findings_report(f"{work}/control")
+ finally:
+ render_report.viewer_td = real
+ missed = tables_without_links(plain)
+ check("report: control: with the links removed the check names every findings table",
+ len(missed) == len(FINDINGS_TABLES) and all("not a viewer link" in m for m in missed), missed)
+ cell = render_report.viewer_td_of
+ check("report: a row's Viewer cell spans the positions on its first chromosome, and is empty without one",
+ cell("chr1:50 A>G + chr1:90 C>T").endswith('#chr1:50-90" target="_blank" rel="noopener noreferrer">'
+ "chr1:50-90")
+ and "#chrM:3243\"" in cell("chrM:3243 A>G") and cell("no locus ") == " | ",
+ [cell("chr1:50 A>G + chr1:90 C>T"), cell("chrM:3243 A>G"), cell("no locus ")])
finally:
if proc and proc.poll() is None:
proc.terminate()
diff --git a/versions.env b/versions.env
index 7ce1069e..7476ff09 100644
--- a/versions.env
+++ b/versions.env
@@ -163,6 +163,14 @@ EIGENSOFT_IMAGE="quay.io/biocontainers/eigensoft:9.0.0--h519d32d_0" # optional:
VCFANNO_IMAGE="quay.io/biocontainers/vcfanno:0.3.9--h1079eea_0"
SLIVAR_IMAGE="quay.io/biocontainers/slivar:0.3.4--hb56abc1_0"
+# --- Genome viewer (scripts/owngenome-view.sh and the live site) ---
+# igv.js (MIT licence, https://github.com/igvteam/igv.js) runs in the browser,
+# so it has no image: both scripts download dist/igv.min.js of this npm
+# release once into GENOME_DIR/tools/igv.js-/ and check it against
+# this sha256. Change the two together.
+IGV_JS_VERSION="3.8.10"
+IGV_JS_SHA256="87b991ae2a720978172e269dd5127c495e56df5af31144dff19c83124e2f5744"
+
# --- Data versions coupled to an image (change them together) ---
# VEP cache release for step 13: must equal the major of VEP_IMAGE.
VEP_CACHE_RELEASE="116"
From f3189dff5963048e8264ad976c8d6d0f3a290ba3 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 4 Nov 2026 16:10:04 +0100
Subject: [PATCH 3/3] fix(viewer): Exomiser rows get the Viewer column; the
live viewer drops tracks with an index out of GENOME_DIR and serves quoted
names
The live viewer compared the still-quoted request name with the file name, so a
reference named with a space, '#' or '?' answered 404. It now unquotes it. An
index symlinked out of GENOME_DIR was served; now its track, file and index are
left out. Each new check has a planted mistake that must fail it.
---
assets/viewer/serve.py | 7 ++--
bin/owngenome_live_viewer.py | 24 +++++++++++---
bin/render_report.py | 7 ++--
docs/24-html-report.md | 2 +-
docs/live-site.md | 4 +--
docs/viewer.md | 4 +--
tests/test_live_viewer.py | 64 ++++++++++++++++++++++++++----------
tests/test_viewer.py | 16 ++++++---
8 files changed, 90 insertions(+), 38 deletions(-)
diff --git a/assets/viewer/serve.py b/assets/viewer/serve.py
index af47ac48..435c9dae 100755
--- a/assets/viewer/serve.py
+++ b/assets/viewer/serve.py
@@ -78,9 +78,10 @@ def index_of(names):
def sample_files(sample_dir, sample):
"""(reads, vcf, sv_vcf) of a sample folder, by the names the steps write,
- None for one that is not there: the files owngenome-view.sh picks when the
- environment names none, and the ones the live site's viewer
- (bin/owngenome_live_viewer.py) serves."""
+ None for one that is not there: the default aligned/ and vcf/ folders, as
+ owngenome-view.sh picks them with none of READS, VCF, SV_VCF, ALIGN_DIR or
+ VCF_DIR set, and the ones the live site's viewer (bin/owngenome_live_viewer.py)
+ serves."""
def first(*names):
return next((p for p in (os.path.join(sample_dir, n) for n in names) if os.path.isfile(p)), None)
return (first(f"aligned/{sample}_sorted.bam", f"aligned/{sample}_sorted.cram"),
diff --git a/bin/owngenome_live_viewer.py b/bin/owngenome_live_viewer.py
index e00d94c5..7edbf1ba 100644
--- a/bin/owngenome_live_viewer.py
+++ b/bin/owngenome_live_viewer.py
@@ -13,8 +13,8 @@
The files are the ones owngenome-view.sh picks by default
(assets/viewer/serve.py sample_files): the run folder's BAM or CRAM, its VCF
-and its SV VCF, each with its index, and the reference. A file outside
-GENOME_DIR (a symlink out of it) is left out. The page and the files carry the
+and its SV VCF, each with its index, and the reference. A track whose file
+or index lies outside GENOME_DIR (a symlink out of it) is left out. The page and the files carry the
viewer's Content-Security-Policy (serve.CSP): the page talks to this site
only. The report the site serves (report.html) gets its viewer links pointed
here. Opening the page writes one access-log row; the byte ranges igv.js reads
@@ -85,7 +85,19 @@ def inside(p):
real = os.path.realpath(p)
return all(real.startswith(t.rstrip(os.sep) + os.sep) for t in (top, root))
reads, vcf, sv_vcf = (p if p and inside(p) else None for p in serve.sample_files(run_dir, run))
- return serve.build_config(run, flag("viewer_fasta"), reads, vcf, sv_vcf)
+ cfg, files = serve.build_config(run, flag("viewer_fasta"), reads, vcf, sv_vcf)
+ # An index beside a track can be a symlink out of GENOME_DIR too: then the track goes, with
+ # its file and its index. Files 0 and 1 are the reference and its .fai (--viewer-fasta, any folder).
+ files = [f if n < 2 or inside(f) else None for n, f in enumerate(files)]
+
+ def nums(t):
+ return [int(t[k].split("/")[1]) for k in ("url", "indexURL") if k in t]
+ for t in cfg["tracks"]:
+ if not all(files[n] for n in nums(t)):
+ for n in nums(t):
+ files[n] = None
+ cfg["tracks"] = [t for t in cfg["tracks"] if all(files[n] for n in nums(t))]
+ return cfg, files
def head_writer(req):
@@ -141,8 +153,10 @@ def viewer(req, m):
if rest == "config.json":
return send(req, 200, "application/json", json.dumps(cfg, indent=1))
dm = DATA_RE.fullmatch(rest)
- if dm and int(dm.group(1)) < len(files) and os.path.basename(files[int(dm.group(1))]) == dm.group(2):
- return serve.send_file(req, head_writer(req), "application/octet-stream", files[int(dm.group(1))])
+ # The config's URLs quote the name (serve.build_config); the path is still quoted here.
+ n = int(dm.group(1)) if dm else len(files)
+ if n < len(files) and files[n] and os.path.basename(files[n]) == urllib.parse.unquote(dm.group(2)):
+ return serve.send_file(req, head_writer(req), "application/octet-stream", files[n])
except ValueError as e:
req.log("viewer", run, status=409)
return req.deny(409, f"the genome viewer cannot open this run: {e}")
diff --git a/bin/render_report.py b/bin/render_report.py
index 3e0f9a9a..da299c86 100755
--- a/bin/render_report.py
+++ b/bin/render_report.py
@@ -625,12 +625,13 @@ def exomiser_html(d, sample):
f"{E(g['phenotype_score'])} | ") if i == 0 else " | " * 5
rows.append(f" {lead}{E(v['variant'])} {E(v['genotype'])} {E(v['consequence'])} {E(v['hgvs'])} | {E(v['acmg'])} | "
- f"{E(v['disease'])} |
")
+ f"{E(v['disease'])} | {viewer_td_of(v['variant'])}
")
body = [stat("Symptoms searched", E(exomiser_terms(d)))]
if rows:
body += ["