@jbrowse/capture 4.3.0 → 5.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/browser.js CHANGED
@@ -1,44 +1,36 @@
1
1
  import fs from 'node:fs';
2
- // Just the sandbox flags, for the scripts that MEASURE cross-origin behaviour
3
- // (COOP/COEP isolation, cancelled cross-origin fetches) and would be measuring
4
- // nothing with web security off. They used to spell this
5
- // `BASE_CHROME_ARGS.filter(a => a !== '--disable-web-security')`, which silently
6
- // stops subtracting the day another security flag is added below.
7
- export const SANDBOX_CHROME_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'];
8
- // Chrome flags both harnesses launch with. Backend-specific flags
9
- // (--disable-gpu, --enable-unsafe-swiftshader, --disable-popup-blocking) are
10
- // appended by each caller.
11
- export const BASE_CHROME_ARGS = [
12
- ...SANDBOX_CHROME_ARGS,
13
- '--disable-web-security',
14
- ];
2
+ import puppeteer from 'puppeteer';
3
+ export const BASE_CHROME_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'];
15
4
  const CHROME_PATHS = [
16
5
  '/usr/bin/google-chrome',
17
6
  '/usr/bin/chromium-browser',
18
7
  '/usr/bin/chromium',
19
8
  ];
20
- // First installed system Chrome/Chromium, or undefined to let Puppeteer use its
21
- // bundled browser. `CHROME_PATH` wins, so a run can be pinned to one binary --
22
- // which is how you answer "did the browser move under us?" when a figure regen
23
- // reports the whole corpus as changed.
24
- //
25
- // Measured 2026-08-02, because that question came up and the answer was no: the
26
- // same spec rendered under Chrome 147.0.7727.57 and 151.0.7922.47 produces
27
- // byte-identical PNGs, and the system package's 151.0.7922.47 -> .71 roll
28
- // likewise changed nothing. So a corpus-wide diff is accumulated app change
29
- // since the last `Bump snaps` sweep, not the browser -- don't chase it here.
9
+ // `CHROME_PATH`, then a system Chrome, then undefined for Puppeteer's own
30
10
  export function findChromeExecutable() {
31
11
  return process.env.CHROME_PATH ?? CHROME_PATHS.find(p => fs.existsSync(p));
32
12
  }
33
- // GPU/WebGL lifecycle chatter that isn't a real error. Real GPU failures
34
- // (`context LOST`, `GL error`) are deliberately NOT suppressed.
13
+ /**
14
+ * The browser `openJBrowse` drives, for a page it cannot open itself: an
15
+ * embedded view on your own host page, navigated and then waited on with
16
+ * `waitForJBrowseReady`.
17
+ */
18
+ export function launchBrowser({ headless = true, executablePath = findChromeExecutable(), args = [], } = {}) {
19
+ return puppeteer.launch({
20
+ headless,
21
+ executablePath,
22
+ args: [...BASE_CHROME_ARGS, ...args],
23
+ });
24
+ }
35
25
  const NOISE_NEEDLES = [
36
26
  'favicon',
27
+ 'window.jb drives this app programmatically',
37
28
  'GPU stall',
38
29
  '[GPU] WebGPU not supported',
39
30
  '[GPU] No compatible GPU adapter',
40
31
  '[GPU] WebGPU initialization failed',
41
32
  '[GPU] WebGL2 unavailable',
33
+ '[GPU] WebGL2 here is software-rendered',
42
34
  '[GPU] WebGPU device creation failed',
43
35
  'GroupMarkerNotSet',
44
36
  'Automatic fallback to software WebGL',
package/esm/capture.d.ts CHANGED
@@ -1,81 +1,13 @@
1
- import type { Instrumentation, PendingDisplay, SessionExpectations } from './sessionGate.ts';
1
+ import type { LaunchOptions } from './browser.ts';
2
+ import type { ReadyOptions, ReadyReport } from './ready.ts';
2
3
  import type { JBrowseUrlOptions } from './url.ts';
3
4
  import type { Browser, Page } from 'puppeteer';
4
- export interface ReadyOptions extends SessionExpectations {
5
- /** Budget for each individual wait stage. */
6
- timeout?: number;
7
- /**
8
- * Also wait out adapter "Downloading…" status text, which can outlive the
9
- * loading overlay when a track streams a large remote file.
10
- */
11
- waitForDownloads?: boolean;
12
- /** Extra settle after everything reports done, for animations and tooltips. */
13
- settleMs?: number;
14
- /**
15
- * Skip the session gate. Only for a page that is not jbrowse-web and so
16
- * publishes no `window.JBrowseSession` — an embedded component, say. You lose
17
- * the only check that the data you asked for is the data on screen.
18
- */
19
- expectSession?: boolean;
20
- /**
21
- * Return the report instead of throwing when a wait stage times out. For
22
- * capturing a page that is deliberately mid-load, or accepting a known-slow
23
- * one. The unsettled stages are still listed in the report either way.
24
- */
25
- allowUnsettled?: boolean;
26
- }
27
- export interface ReadyReport {
28
- /** Displays still reporting unpainted at the end of the wait. */
29
- pending: string[];
30
- /**
31
- * The same displays with the phase each one publishes, which is what
32
- * separates a slow fetch from a display that says it finished without
33
- * painting. Empty whenever `pending` is.
34
- */
35
- pendingStates: PendingDisplay[];
36
- /**
37
- * Whether every display's paint state was actually measurable — false when
38
- * tracks are open but the build publishes no `data-display-drawn`. With this
39
- * false, an empty `pending` means "cannot tell", not "all done".
40
- */
41
- paintContract: boolean;
42
- /** Wait stages that hit their timeout instead of being satisfied. */
43
- unsettled: string[];
44
- /**
45
- * Which readiness attributes the build published, read once the session was
46
- * up. Every `false` here is a wait that could not fail rather than one that
47
- * passed, and the chain compensates for it — see LEGACY_QUIET_MS.
48
- */
49
- instrumentation: Instrumentation;
50
- /**
51
- * Whether the page published `[data-app-phase]`, the one positive readiness
52
- * selector. True means the wait was that selector and nothing else; false
53
- * means the build predates it and the fallback chain ran.
54
- */
55
- appMarker: boolean;
56
- }
57
- /**
58
- * Wait until a JBrowse page has finished loading AND finished drawing.
59
- *
60
- * One positive gate, then the negative ones. The order is the whole point: the
61
- * DOM waits all pass on a page that has not started yet, so without the session
62
- * gate in front the chain returns in under a second (see sessionGate.ts). After
63
- * it, each stage is only meaningful once the previous has passed — a view still
64
- * resolving its assembly has mounted no displays, and a display that has mounted
65
- * but not fetched has nothing to be drawn yet.
66
- */
67
- export declare function waitForJBrowseReady(page: Page, { timeout, waitForDownloads, settleMs, expectSession, assembly, trackIds, allowUnsettled, }?: ReadyOptions): Promise<ReadyReport>;
68
- export interface OpenOptions extends JBrowseUrlOptions, ReadyOptions {
5
+ export interface OpenOptions extends JBrowseUrlOptions, ReadyOptions, LaunchOptions {
69
6
  width?: number;
70
7
  height?: number;
71
- /** 2 renders a retina-density image, which is what a figure usually wants. */
8
+ /** Device pixel ratio. Default 2, the density a figure usually wants. */
72
9
  deviceScaleFactor?: number;
73
- headless?: boolean;
74
- /** Chrome binary. Defaults to $CHROME_PATH, a system Chrome, then Puppeteer's own. */
75
- executablePath?: string;
76
- /** Extra Chrome flags, appended to the defaults. */
77
- args?: string[];
78
- /** Called with each page console message that is not known GPU noise. */
10
+ /** Called with each page console message that is not known GPU noise, and each uncaught page error. */
79
11
  onConsole?: (text: string) => void;
80
12
  }
81
13
  export interface OpenResult extends ReadyReport {
@@ -84,22 +16,22 @@ export interface OpenResult extends ReadyReport {
84
16
  url: string;
85
17
  }
86
18
  /**
87
- * Launch a browser, navigate to a JBrowse session, and wait until it has
88
- * rendered. The caller owns the returned browser and must close it.
89
- *
90
- * Use this when the screenshot is not the end of the job: clicking a feature,
91
- * reading state back out of `window.JBrowseSession`, capturing a dialog. For a
92
- * plain image, `captureJBrowse` wraps the whole thing.
93
- *
94
- * The assembly and track count asked for in the URL become the session gate's
95
- * expectations unless you override them, so a mistyped trackId fails here rather
96
- * than producing an image of an empty browser.
19
+ * Launch a browser, open a JBrowse session, and wait until it has rendered.
20
+ * The caller owns the returned browser and must close it. The assembly and
21
+ * tracks asked for become the session gate's expectations unless `trackIds`
22
+ * names others.
97
23
  */
98
24
  export declare function openJBrowse(options?: OpenOptions): Promise<OpenResult>;
99
25
  export interface CaptureOptions extends OpenOptions {
100
- /** PNG path to write. Omit to get the buffer back and write it yourself. */
26
+ /**
27
+ * Image path to write, `.png`, `.jpg`/`.jpeg` or `.webp`. Omit to get the PNG
28
+ * buffer back and write it yourself.
29
+ */
101
30
  out?: string;
102
- /** Capture the whole scrollable page rather than the viewport. */
31
+ /**
32
+ * Capture every view rather than the viewport: the viewport grows by however
33
+ * far the session runs past it, and the frame is waited for again.
34
+ */
103
35
  fullPage?: boolean;
104
36
  }
105
37
  export interface CaptureResult extends ReadyReport {
@@ -107,7 +39,7 @@ export interface CaptureResult extends ReadyReport {
107
39
  image: Uint8Array;
108
40
  }
109
41
  /**
110
- * Open a JBrowse session, wait for it to render, screenshot it, close the
111
- * browser. The one-call form.
42
+ * Open a JBrowse session, wait for it to render, screenshot it, and close the
43
+ * browser.
112
44
  */
113
45
  export declare function captureJBrowse(options?: CaptureOptions): Promise<CaptureResult>;
package/esm/capture.js CHANGED
@@ -1,213 +1,33 @@
1
- import puppeteer from 'puppeteer';
2
- import { BASE_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.js';
3
- import { assemblyFromSession, trackIdsFromSession } from './session.js';
4
- import { describePendingDisplays, hasPaintContract, pendingDisplayStates, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
5
- import { jbrowseUrl } from './url.js';
6
- import { delay, hasAppReadyMarker, waitForAppReady, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
7
- // A build with no per-display paint attributes gives no signal between "the
8
- // overlay cleared" and "the canvas has pixels on it", so the only thing left is
9
- // to wait. Sized from the measured gap on jbrowse.org/code/jb2/latest between
10
- // the overlay clearing and the tracks being fully drawn.
11
- const LEGACY_PAINT_SETTLE_MS = 1500;
12
- // ...and the gap at the OTHER end, which is the one that lands a capture on an
13
- // app that has drawn nothing at all. Measured on the same instance with two
14
- // remote tracks: the session gate is satisfied at ~2.5s, and the loading overlay
15
- // — the only remaining signal there — does not go up until ~3.5s. An
16
- // instrumented build has no such window: `data-display-phase` appears in the
17
- // same frame as the display it belongs to.
18
- //
19
- // So on an uninstrumented build the app has to be seen to START working, and
20
- // then hold still, before it counts as finished. The window is how long the
21
- // first indicator gets to appear before "nothing to wait for" is the better
22
- // reading; the quiet is how long the idle then has to hold.
23
- const LEGACY_BUSY_WINDOW_MS = 4000;
24
- const LEGACY_QUIET_MS = 2000;
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { isBrowserConsoleNoise, launchBrowser } from './browser.js';
4
+ import { resolveAgainstConfig } from './catalog.js';
5
+ import { assertImagePath } from './imagePath.js';
6
+ import { assertSupportedInstance } from './instanceVersion.js';
7
+ import { DEFAULT_TIMEOUT } from './poll.js';
8
+ import { waitForFrame, waitForJBrowseReady } from './ready.js';
9
+ import { assemblyFromSession, savedSnapshot, trackIdsFromSession, } from './session.js';
10
+ import { sessionOverflowInPage } from './sessionOverflow.js';
11
+ import { PUBLIC_INSTANCE, assertSessionStandsAlone, jbrowseUrl } from './url.js';
25
12
  /**
26
- * Wait until a JBrowse page has finished loading AND finished drawing.
27
- *
28
- * One positive gate, then the negative ones. The order is the whole point: the
29
- * DOM waits all pass on a page that has not started yet, so without the session
30
- * gate in front the chain returns in under a second (see sessionGate.ts). After
31
- * it, each stage is only meaningful once the previous has passed — a view still
32
- * resolving its assembly has mounted no displays, and a display that has mounted
33
- * but not fetched has nothing to be drawn yet.
34
- */
35
- export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownloads = true, settleMs = 0, expectSession = true, assembly, trackIds, allowUnsettled = false, } = {}) {
36
- const unsettled = [];
37
- const stage = async (name, work) => {
38
- if (!(await work)) {
39
- unsettled.push(name);
40
- }
41
- };
42
- // The two hard waits reject with puppeteer's own `Waiting failed: Nms
43
- // exceeded`, which names neither the stage nor the selector — the whole
44
- // failure mode this module exists to avoid, arriving as an error message
45
- // instead of as a blank image. Say which gate it was and what to do.
46
- const required = async (name, work) => {
47
- try {
48
- return await work;
49
- }
50
- catch {
51
- throw new Error(`gave up waiting after ${timeout}ms: ${name}. Raise the timeout if the ` +
52
- 'page is merely slow; if it never finishes, open the same URL in a ' +
53
- 'browser — this gate has no content to fall through to.');
54
- }
55
- };
56
- // 0. the session exists and holds what was asked for. Positive, and throws.
57
- if (expectSession) {
58
- await waitForSession(page, { assembly, trackIds, timeout });
59
- }
60
- // 1. THE WHOLE ANSWER, on any build that has it: the session renders
61
- // `[data-app-phase="ready"]` when no view is resolving an assembly and no
62
- // display is fetching. It is positive, so unlike everything below it
63
- // cannot be satisfied by an app that has not started, and there is nothing
64
- // to assemble — wait for the selector and stop.
65
- //
66
- // Everything after this point is the fallback for a deployment older than
67
- // the marker, and can be deleted the day the oldest supported build has
68
- // it.
69
- if (await hasAppReadyMarker(page)) {
70
- const ready = await waitForAppReady(page, { timeout });
71
- if (!ready) {
72
- unsettled.push('the app never reported itself ready');
73
- }
74
- // One thing the marker does not answer, so this stage stays even here: the
75
- // marker is about WORK, and a display whose fetch failed is not working. It
76
- // reads `ready` over an error banner, which is a correct answer to a
77
- // different question than a capture is asking. `data-display-drawn` is the
78
- // stricter gate — the two comparative canvases publish it from `settled`,
79
- // which holds an error open deliberately so a golden regenerated during an
80
- // outage fails here instead of absorbing the banner as expected output.
81
- // Ordered after the marker rather than instead of it, which is what makes an
82
- // absence meaningful (see waitForDisplaysDone), and free on a page that has
83
- // no such canvas.
84
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
85
- if (settleMs > 0) {
86
- await delay(settleMs);
87
- }
88
- const pendingStates = await pendingDisplayStates(page);
89
- const report = {
90
- pending: pendingStates.map(d => d.name),
91
- pendingStates,
92
- paintContract: await hasPaintContract(page),
93
- unsettled,
94
- instrumentation: await readInstrumentation(page),
95
- appMarker: true,
96
- };
97
- if (!allowUnsettled && unsettled.length > 0) {
98
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
99
- }
100
- return report;
101
- }
102
- // 1b. what an older build can be asked instead. On one that publishes none of
103
- // these, the stages below are not gates at all: they are assertions about
104
- // absent attributes that no page can fail.
105
- const instrumentation = await readInstrumentation(page);
106
- const instrumented = instrumentation.displayPhase || instrumentation.displayDrawn;
107
- // A page with no tracks open has nothing to load, so the quiet gate below
108
- // would be a fixed sleep for an import form or a menu shot.
109
- const summary = await readSessionSummary(page);
110
- const needsQuietGate = !instrumented && (summary?.trackIds.length ?? 0) > 0;
111
- // 2. the view has an assembly and its React component has arrived. Also not
112
- // best-effort: a view stuck here has no content to fall through to.
113
- await required('a view never left its loading phase (still resolving its assembly, or its ' +
114
- 'lazily-imported component never arrived)', waitForViewPhases(page, timeout));
115
- // 2. no track is still fetching. One call, two outcomes: the overlay half is
116
- // required (an overlay that never clears is a fetch that never finished,
117
- // with nothing behind it) and throws; the "Downloading…" half is
118
- // best-effort and comes back as the boolean.
119
- const downloadsSettled = await required('the loading overlay never cleared (a track fetch never finished)', waitForLoadingComplete(page, { timeout, waitForDownloads }));
120
- if (!downloadsSettled) {
121
- unsettled.push('a track was still downloading');
122
- }
123
- await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
124
- // 3. no display is still pending its first paint
125
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
126
- // 4. no visible "Loading…/Rendering…/Computing…" text remains, which is how
127
- // the views that publish no phase attribute report themselves
128
- await stage('a "Loading…/Rendering…" label was still on screen', waitForQuiescent(page, { timeout }));
129
- // 5. and on a build that answered none of the above, the one gate that does
130
- // not depend on an attribute existing: the app has to hold still. Every
131
- // stage before this one passed the moment it was asked, so without it the
132
- // chain returns while the first fetch is still being set up.
133
- if (needsQuietGate) {
134
- await stage('the app never went quiet for ' +
135
- `${LEGACY_QUIET_MS}ms (this build publishes no readiness attributes, ` +
136
- 'so being seen to work and then stop is the only finished signal there ' +
137
- 'is)', waitForQuietPeriod(page, {
138
- quietMs: LEGACY_QUIET_MS,
139
- busyWindowMs: LEGACY_BUSY_WINDOW_MS,
140
- timeout,
141
- }));
142
- }
143
- const paintContract = await hasPaintContract(page);
144
- if (!paintContract) {
145
- await delay(LEGACY_PAINT_SETTLE_MS);
146
- }
147
- if (settleMs > 0) {
148
- await delay(settleMs);
149
- }
150
- const pendingStates = await pendingDisplayStates(page);
151
- const report = {
152
- pending: pendingStates.map(d => d.name),
153
- pendingStates,
154
- paintContract,
155
- unsettled,
156
- instrumentation,
157
- appMarker: false,
158
- };
159
- if (!allowUnsettled && unsettled.length > 0) {
160
- // Throwing is the point. Each of these stages swallows its own timeout so a
161
- // slow page is not failed for being slow, which historically meant the run
162
- // ended with an image and an exit code of 0 whether it had settled or not.
163
- // A caller that genuinely wants the frame anyway asks for it by name.
164
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
165
- }
166
- return report;
167
- }
168
- /**
169
- * What a timed-out wait says. The stage names alone were the whole message, and
170
- * they name the QUESTION rather than the answer — "a display never reported its
171
- * first paint" reads identically for a slow fetch, a failed one and a display
172
- * that never had a canvas to paint. Appending the census answers it, and the
173
- * `ready` case is the one that most changes what a reader does next: a longer
174
- * timeout is the fix for `loading` and never the fix for that.
175
- */
176
- function unsettledMessage(timeout, unsettled, pending) {
177
- return (`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ${pending.length > 0
178
- ? `Still unpainted: ${describePendingDisplays(pending)}. `
179
- : ''}Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ` +
180
- `capture the frame as it stands.`);
181
- }
182
- /**
183
- * Launch a browser, navigate to a JBrowse session, and wait until it has
184
- * rendered. The caller owns the returned browser and must close it.
185
- *
186
- * Use this when the screenshot is not the end of the job: clicking a feature,
187
- * reading state back out of `window.JBrowseSession`, capturing a dialog. For a
188
- * plain image, `captureJBrowse` wraps the whole thing.
189
- *
190
- * The assembly and track count asked for in the URL become the session gate's
191
- * expectations unless you override them, so a mistyped trackId fails here rather
192
- * than producing an image of an empty browser.
13
+ * Launch a browser, open a JBrowse session, and wait until it has rendered.
14
+ * The caller owns the returned browser and must close it. The assembly and
15
+ * tracks asked for become the session gate's expectations unless `trackIds`
16
+ * names others.
193
17
  */
194
18
  export async function openJBrowse(options = {}) {
195
- const { width = 1400, height = 900, deviceScaleFactor = 2, headless = true, executablePath = findChromeExecutable(), args = [], onConsole, timeout, trackIds,
196
- // Pulled out only to keep them out of `urlOptions`, which becomes the
197
- // query string. They reach the ready wait through the `...options` spread
198
- // below rather than from here.
199
- waitForDownloads: _waitForDownloads, settleMs: _settleMs, expectSession: _expectSession, allowUnsettled: _allowUnsettled,
200
- // `assembly` is deliberately NOT pulled out here: it is both a URL option
201
- // (which assembly to open) and the session gate's expectation (which
202
- // assembly must end up open), and destructuring it for the second use would
203
- // have dropped it from the first, silently.
204
- ...urlOptions } = options;
205
- const url = jbrowseUrl(urlOptions);
206
- const browser = await puppeteer.launch({
207
- headless,
208
- executablePath,
209
- args: [...BASE_CHROME_ARGS, ...args],
19
+ const { width = 1400, height = 900, deviceScaleFactor = 2, headless, executablePath, args, onConsole, timeout = DEFAULT_TIMEOUT, trackIds, ...given } = options;
20
+ assertSessionStandsAlone(given);
21
+ const [urlOptions] = await Promise.all([
22
+ resolveAgainstConfig(given),
23
+ assertSupportedInstance(given.instance ?? PUBLIC_INSTANCE),
24
+ ]);
25
+ // named, so the header carries no timestamp that differs on every capture
26
+ const url = jbrowseUrl({
27
+ ...urlOptions,
28
+ sessionName: urlOptions.sessionName ?? 'Screenshot',
210
29
  });
30
+ const browser = await launchBrowser({ headless, executablePath, args });
211
31
  try {
212
32
  const page = await browser.newPage();
213
33
  await page.setViewport({ width, height, deviceScaleFactor });
@@ -218,53 +38,50 @@ export async function openJBrowse(options = {}) {
218
38
  onConsole(text);
219
39
  }
220
40
  });
41
+ page.on('pageerror', error => {
42
+ onConsole(`uncaught ${error instanceof Error ? error.stack : error}`);
43
+ });
221
44
  }
222
- // domcontentloaded, not networkidle2: an app that streams track data may
223
- // never go idle, and the session gate below is a far better "it is up"
224
- // signal than the absence of requests.
45
+ // an app streaming track data may never reach networkidle; the session
46
+ // gate is the signal that it is up
225
47
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
48
+ const { spec, session, assembly, hub, tracks } = urlOptions;
49
+ const opens = spec ?? (session && savedSnapshot(session));
226
50
  const report = await waitForJBrowseReady(page, {
227
- // Spread, not a hand-copied field list. `OpenOptions extends
228
- // ReadyOptions`, so every present and future ready option arrives here by
229
- // construction. Listing them by hand is what dropped `allowUnsettled`:
230
- // it was declared, documented, recommended by this function's own timeout
231
- // message, and silently never forwarded, so `--allowUnsettled` did
232
- // nothing and a timing-out stage always threw. Only the two options
233
- // derived from the URL are overridden below.
234
51
  ...options,
235
- // A session spec's own assembly wins over the hub name. `--hub hg38
236
- // --session spec.json` where the spec opens something else is legitimate
237
- // (the hub is just supplying the config), and expecting the hub name there
238
- // would fail a capture that is entirely correct.
239
- assembly: urlOptions.assembly ??
240
- (urlOptions.session
241
- ? assemblyFromSession(urlOptions.session)
242
- : undefined) ??
243
- (urlOptions.session ? undefined : urlOptions.hub),
244
- trackIds: trackIds ??
245
- urlOptions.tracks ??
246
- (urlOptions.session
247
- ? trackIdsFromSession(urlOptions.session)
248
- : undefined),
52
+ assembly: opens ? assemblyFromSession(opens) : (assembly ?? hub),
53
+ trackIds: trackIds ?? (opens ? trackIdsFromSession(opens) : tracks),
249
54
  });
250
55
  return { browser, page, url, ...report };
251
56
  }
252
57
  catch (error) {
253
- // A launch that got as far as a page and then failed still holds a Chrome
254
- // process; without this the caller has no handle to close it.
255
- await browser.close();
58
+ await browser.close().catch(() => { });
256
59
  throw error;
257
60
  }
258
61
  }
259
62
  /**
260
- * Open a JBrowse session, wait for it to render, screenshot it, close the
261
- * browser. The one-call form.
63
+ * Open a JBrowse session, wait for it to render, screenshot it, and close the
64
+ * browser.
262
65
  */
263
66
  export async function captureJBrowse(options = {}) {
264
67
  const { out, fullPage = false, ...openOptions } = options;
265
- const { browser, page, url, ...report } = await openJBrowse(openOptions);
68
+ if (out) {
69
+ assertImagePath(out);
70
+ mkdirSync(dirname(out), { recursive: true });
71
+ }
72
+ const { browser, page, url, ...opened } = await openJBrowse(openOptions);
266
73
  try {
267
- const image = await page.screenshot({ path: out, fullPage });
74
+ let report = opened;
75
+ const overflow = fullPage ? await page.evaluate(sessionOverflowInPage) : 0;
76
+ const viewport = page.viewport();
77
+ if (viewport && overflow > 0) {
78
+ await page.setViewport({
79
+ ...viewport,
80
+ height: viewport.height + overflow,
81
+ });
82
+ report = await waitForFrame(page, openOptions);
83
+ }
84
+ const image = await page.screenshot({ path: out });
268
85
  return { url, image, ...report };
269
86
  }
270
87
  finally {
@@ -0,0 +1,32 @@
1
+ import type { Track } from './hub.ts';
2
+ import type { JBrowseUrlOptions } from './url.ts';
3
+ interface CatalogAssembly {
4
+ name: string;
5
+ aliases?: string[];
6
+ }
7
+ /** The parts of a JBrowse config.json that name what can be opened. */
8
+ export interface Catalog {
9
+ assemblies?: CatalogAssembly[];
10
+ assembly?: CatalogAssembly;
11
+ tracks?: Track[];
12
+ }
13
+ /**
14
+ * The canonical name of the assembly `input` names by name or alias, ignoring
15
+ * case: the name the app's census publishes.
16
+ */
17
+ export declare function resolveAssemblyName(catalog: Catalog, input: string): string;
18
+ /**
19
+ * The trackId `input` names: an exact trackId, then the id with the assembly
20
+ * prefix a hosted config puts on every track, then a single case-insensitive
21
+ * match on the id, the unprefixed id or the display name. A miss suggests the
22
+ * tracks whose id or name contains the input.
23
+ */
24
+ export declare function resolveTrackId(tracks: Track[], input: string, assemblyName: string): string;
25
+ /**
26
+ * The options with the assembly and tracks checked against the config and
27
+ * spelled the way it spells them, so a typo fails before a browser launches.
28
+ * A config this cannot fetch passes through for the app to judge, and so does
29
+ * a spec or saved session, which may declare tracks no config lists.
30
+ */
31
+ export declare function resolveAgainstConfig(options: JBrowseUrlOptions): Promise<JBrowseUrlOptions>;
32
+ export {};
package/esm/catalog.js ADDED
@@ -0,0 +1,84 @@
1
+ import { fetchJson, hubUrl, trackName, tracksMatching } from './hub.js';
2
+ import { instanceUrl } from './url.js';
3
+ const assembliesOf = (catalog) => catalog.assemblies ?? (catalog.assembly ? [catalog.assembly] : []);
4
+ /**
5
+ * The canonical name of the assembly `input` names by name or alias, ignoring
6
+ * case: the name the app's census publishes.
7
+ */
8
+ export function resolveAssemblyName(catalog, input) {
9
+ const assemblies = assembliesOf(catalog);
10
+ const target = input.toLowerCase();
11
+ const found = assemblies.find(a => [a.name, ...(a.aliases ?? [])].some(n => n.toLowerCase() === target));
12
+ if (!found) {
13
+ const names = assemblies.map(a => a.name).join(', ');
14
+ throw new Error(`assembly "${input}" is not in the config, which has ${names || 'none'}`);
15
+ }
16
+ return found.name;
17
+ }
18
+ /**
19
+ * The trackId `input` names: an exact trackId, then the id with the assembly
20
+ * prefix a hosted config puts on every track, then a single case-insensitive
21
+ * match on the id, the unprefixed id or the display name. A miss suggests the
22
+ * tracks whose id or name contains the input.
23
+ */
24
+ export function resolveTrackId(tracks, input, assemblyName) {
25
+ const ids = new Set(tracks.map(t => t.trackId));
26
+ const prefixed = `${assemblyName}-${input}`;
27
+ if (ids.has(input)) {
28
+ return input;
29
+ }
30
+ if (ids.has(prefixed)) {
31
+ return prefixed;
32
+ }
33
+ const target = input.toLowerCase();
34
+ const prefix = `${assemblyName}-`.toLowerCase();
35
+ const loose = tracks.filter(t => {
36
+ const id = t.trackId.toLowerCase();
37
+ return (id === target ||
38
+ (id.startsWith(prefix) && id.slice(prefix.length) === target) ||
39
+ trackName(t).toLowerCase() === target);
40
+ });
41
+ if (loose.length === 1) {
42
+ return loose[0].trackId;
43
+ }
44
+ if (loose.length > 1) {
45
+ throw new Error(`--track "${input}" is ambiguous; matches: ${loose.map(t => t.trackId).join(', ')}`);
46
+ }
47
+ const suggestions = tracksMatching(tracks, input)
48
+ .slice(0, 8)
49
+ .map(t => t.trackId);
50
+ const hint = suggestions.length
51
+ ? `. Did you mean: ${suggestions.join(', ')}?`
52
+ : '';
53
+ throw new Error(`--track "${input}" is not in the config${hint}`);
54
+ }
55
+ /**
56
+ * The options with the assembly and tracks checked against the config and
57
+ * spelled the way it spells them, so a typo fails before a browser launches.
58
+ * A config this cannot fetch passes through for the app to judge, and so does
59
+ * a spec or saved session, which may declare tracks no config lists.
60
+ */
61
+ export async function resolveAgainstConfig(options) {
62
+ const { hub, config, assembly, tracks, spec, session, instance } = options;
63
+ const wanted = assembly ?? hub;
64
+ const url = config
65
+ ? new URL(config, instanceUrl(instance)).href
66
+ : hub
67
+ ? hubUrl(hub)
68
+ : undefined;
69
+ if (spec || session || !url || (!wanted && !tracks?.length)) {
70
+ return options;
71
+ }
72
+ const catalog = (await fetchJson(url, 'config').catch(() => undefined));
73
+ if (!catalog) {
74
+ return options;
75
+ }
76
+ const assemblyName = wanted
77
+ ? resolveAssemblyName(catalog, wanted)
78
+ : assembliesOf(catalog)[0]?.name;
79
+ return {
80
+ ...options,
81
+ assembly: wanted ? assemblyName : undefined,
82
+ tracks: tracks?.map(t => resolveTrackId(catalog.tracks ?? [], t, assemblyName ?? '')),
83
+ };
84
+ }