@jbrowse/capture 4.3.0
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/LICENSE +201 -0
- package/README.md +124 -0
- package/esm/args.d.ts +30 -0
- package/esm/args.js +79 -0
- package/esm/bin.d.ts +2 -0
- package/esm/bin.js +165 -0
- package/esm/browser.d.ts +4 -0
- package/esm/browser.js +53 -0
- package/esm/capture.d.ts +113 -0
- package/esm/capture.js +273 -0
- package/esm/hub.d.ts +23 -0
- package/esm/hub.js +43 -0
- package/esm/index.d.ts +10 -0
- package/esm/index.js +7 -0
- package/esm/session.d.ts +25 -0
- package/esm/session.js +61 -0
- package/esm/sessionGate.d.ts +126 -0
- package/esm/sessionGate.js +196 -0
- package/esm/url.d.ts +39 -0
- package/esm/url.js +44 -0
- package/esm/waits.d.ts +214 -0
- package/esm/waits.js +428 -0
- package/package.json +51 -0
package/esm/capture.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Instrumentation, PendingDisplay, SessionExpectations } from './sessionGate.ts';
|
|
2
|
+
import type { JBrowseUrlOptions } from './url.ts';
|
|
3
|
+
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 {
|
|
69
|
+
width?: number;
|
|
70
|
+
height?: number;
|
|
71
|
+
/** 2 renders a retina-density image, which is what a figure usually wants. */
|
|
72
|
+
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. */
|
|
79
|
+
onConsole?: (text: string) => void;
|
|
80
|
+
}
|
|
81
|
+
export interface OpenResult extends ReadyReport {
|
|
82
|
+
browser: Browser;
|
|
83
|
+
page: Page;
|
|
84
|
+
url: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
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.
|
|
97
|
+
*/
|
|
98
|
+
export declare function openJBrowse(options?: OpenOptions): Promise<OpenResult>;
|
|
99
|
+
export interface CaptureOptions extends OpenOptions {
|
|
100
|
+
/** PNG path to write. Omit to get the buffer back and write it yourself. */
|
|
101
|
+
out?: string;
|
|
102
|
+
/** Capture the whole scrollable page rather than the viewport. */
|
|
103
|
+
fullPage?: boolean;
|
|
104
|
+
}
|
|
105
|
+
export interface CaptureResult extends ReadyReport {
|
|
106
|
+
url: string;
|
|
107
|
+
image: Uint8Array;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Open a JBrowse session, wait for it to render, screenshot it, close the
|
|
111
|
+
* browser. The one-call form.
|
|
112
|
+
*/
|
|
113
|
+
export declare function captureJBrowse(options?: CaptureOptions): Promise<CaptureResult>;
|
package/esm/capture.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
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;
|
|
25
|
+
/**
|
|
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.
|
|
193
|
+
*/
|
|
194
|
+
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],
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
const page = await browser.newPage();
|
|
213
|
+
await page.setViewport({ width, height, deviceScaleFactor });
|
|
214
|
+
if (onConsole) {
|
|
215
|
+
page.on('console', msg => {
|
|
216
|
+
const text = msg.text();
|
|
217
|
+
if (!isBrowserConsoleNoise(text)) {
|
|
218
|
+
onConsole(text);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
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.
|
|
225
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
|
226
|
+
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
|
+
...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),
|
|
249
|
+
});
|
|
250
|
+
return { browser, page, url, ...report };
|
|
251
|
+
}
|
|
252
|
+
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();
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Open a JBrowse session, wait for it to render, screenshot it, close the
|
|
261
|
+
* browser. The one-call form.
|
|
262
|
+
*/
|
|
263
|
+
export async function captureJBrowse(options = {}) {
|
|
264
|
+
const { out, fullPage = false, ...openOptions } = options;
|
|
265
|
+
const { browser, page, url, ...report } = await openJBrowse(openOptions);
|
|
266
|
+
try {
|
|
267
|
+
const image = await page.screenshot({ path: out, fullPage });
|
|
268
|
+
return { url, image, ...report };
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await browser.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
package/esm/hub.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function hubUrl(hub: string): string;
|
|
2
|
+
interface HubTrack {
|
|
3
|
+
trackId: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
interface HubConfig {
|
|
8
|
+
tracks?: HubTrack[];
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fetch a hosted assembly config from genomes.jbrowse.org by UCSC database name
|
|
13
|
+
* or GenArk accession. Use it to discover which trackIds an assembly publishes
|
|
14
|
+
* before naming them in a session.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fetchHubConfig(hub: string): Promise<HubConfig>;
|
|
17
|
+
/**
|
|
18
|
+
* The trackIds a hosted assembly publishes, optionally narrowed to those whose
|
|
19
|
+
* id or display name contains `filter`. Case-insensitive, because a hub carries
|
|
20
|
+
* hundreds of tracks and an agent picking one from a name is the common case.
|
|
21
|
+
*/
|
|
22
|
+
export declare function listHubTracks(hub: string, filter?: string): Promise<HubTrack[]>;
|
|
23
|
+
export {};
|
package/esm/hub.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const HUB_HOST = 'https://jbrowse.org';
|
|
2
|
+
// The genomes.jbrowse.org URL scheme: a UCSC database name (hg38, mm10, ...)
|
|
3
|
+
// maps to /ucsc/<db>/config.json; a GenArk accession (GCA_/GCF_...) fans its 9
|
|
4
|
+
// digits out into a 3-level directory tree.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately a copy of `hubUrl` in packages/core/src/util/fetchHub.ts rather
|
|
7
|
+
// than an import of it. This package is what an agent reaches for with a bare
|
|
8
|
+
// `npx`, and its only runtime dependency is puppeteer; pulling in @jbrowse/core
|
|
9
|
+
// (react, mobx, the whole state tree) to evaluate one regex would be the largest
|
|
10
|
+
// thing in the install by far. `hub.test.ts` imports both and asserts they agree
|
|
11
|
+
// on every shape, so the copy cannot drift silently.
|
|
12
|
+
export function hubUrl(hub) {
|
|
13
|
+
const genark = /^(GC[AF])_(\d{3})(\d{3})(\d{3})/.exec(hub);
|
|
14
|
+
return genark
|
|
15
|
+
? `${HUB_HOST}/hubs/genark/${genark[1]}/${genark[2]}/${genark[3]}/${genark[4]}/${hub}/config.json`
|
|
16
|
+
: `${HUB_HOST}/ucsc/${hub}/config.json`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Fetch a hosted assembly config from genomes.jbrowse.org by UCSC database name
|
|
20
|
+
* or GenArk accession. Use it to discover which trackIds an assembly publishes
|
|
21
|
+
* before naming them in a session.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchHubConfig(hub) {
|
|
24
|
+
const url = hubUrl(hub);
|
|
25
|
+
const res = await fetch(url);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error(`hub "${hub}" not found (HTTP ${res.status} from ${url}). ` +
|
|
28
|
+
'See https://genomes.jbrowse.org for the available assemblies.');
|
|
29
|
+
}
|
|
30
|
+
return (await res.json());
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The trackIds a hosted assembly publishes, optionally narrowed to those whose
|
|
34
|
+
* id or display name contains `filter`. Case-insensitive, because a hub carries
|
|
35
|
+
* hundreds of tracks and an agent picking one from a name is the common case.
|
|
36
|
+
*/
|
|
37
|
+
export async function listHubTracks(hub, filter) {
|
|
38
|
+
const { tracks = [] } = await fetchHubConfig(hub);
|
|
39
|
+
const needle = filter?.toLowerCase();
|
|
40
|
+
return needle
|
|
41
|
+
? tracks.filter(t => `${t.trackId} ${t.name ?? ''}`.toLowerCase().includes(needle))
|
|
42
|
+
: tracks;
|
|
43
|
+
}
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { captureJBrowse, openJBrowse, waitForJBrowseReady } from './capture.ts';
|
|
2
|
+
export { PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.ts';
|
|
3
|
+
export { PUBLIC_INSTANCE, jbrowseUrl } from './url.ts';
|
|
4
|
+
export { fetchHubConfig, hubUrl, listHubTracks } from './hub.ts';
|
|
5
|
+
export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.ts';
|
|
6
|
+
export { APP_READY, BUSY_SELECTOR, LOADING_OVERLAY, PENDING_DISPLAYS, delay, describePendingDisplaysNow, displayById, displayPainted, displaySettled, hasAppReadyMarker, isPageBusyInPage, waitForAppReady, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForSelectorAttributed, waitForViewPhases, } from './waits.ts';
|
|
7
|
+
export { BASE_CHROME_ARGS, SANDBOX_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.ts';
|
|
8
|
+
export type { CaptureOptions, CaptureResult, OpenOptions, OpenResult, ReadyOptions, ReadyReport, } from './capture.ts';
|
|
9
|
+
export type { Instrumentation, PendingDisplay, SessionExpectations, SessionSummary, } from './sessionGate.ts';
|
|
10
|
+
export type { JBrowseUrlOptions } from './url.ts';
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { captureJBrowse, openJBrowse, waitForJBrowseReady } from './capture.js';
|
|
2
|
+
export { PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
|
|
3
|
+
export { PUBLIC_INSTANCE, jbrowseUrl } from './url.js';
|
|
4
|
+
export { fetchHubConfig, hubUrl, listHubTracks } from './hub.js';
|
|
5
|
+
export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.js';
|
|
6
|
+
export { APP_READY, BUSY_SELECTOR, LOADING_OVERLAY, PENDING_DISPLAYS, delay, describePendingDisplaysNow, displayById, displayPainted, displaySettled, hasAppReadyMarker, isPageBusyInPage, waitForAppReady, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForSelectorAttributed, waitForViewPhases, } from './waits.js';
|
|
7
|
+
export { BASE_CHROME_ARGS, SANDBOX_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.js';
|
package/esm/session.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare function sessionSpecParam(session: object): string;
|
|
2
|
+
export declare function encodeSessionSpec(session: object): string;
|
|
3
|
+
/**
|
|
4
|
+
* The assembly a session spec's first view opens, if it names one.
|
|
5
|
+
*
|
|
6
|
+
* The session gate needs this because a spec can be loaded against any config —
|
|
7
|
+
* `--hub hg38 --session spec.json` where the spec opens a different assembly is
|
|
8
|
+
* legitimate, and defaulting the expectation to the hub name would fail a
|
|
9
|
+
* perfectly good capture.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assemblyFromSession(session: object): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Every trackId a session spec asks a view to open, including nested views (a
|
|
14
|
+
* synteny or breakpoint-split spec puts its LGVs one level down).
|
|
15
|
+
*
|
|
16
|
+
* Used as the session gate's expectation: it is what turns "the browser loaded"
|
|
17
|
+
* into "the tracks I named are open", and a spec is the one input where the
|
|
18
|
+
* caller has not spelled those ids out separately.
|
|
19
|
+
*/
|
|
20
|
+
export declare function trackIdsFromSession(session: object): string[];
|
|
21
|
+
export declare function sessionSpecQuery({ config, session, sessionName, }: {
|
|
22
|
+
config: string;
|
|
23
|
+
session: object;
|
|
24
|
+
sessionName?: string;
|
|
25
|
+
}): string;
|
package/esm/session.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The DECODED value of the `session=` query param that jbrowse-web reads to open
|
|
2
|
+
// a declarative session without a saved-session backend: a `spec-` prefix plus
|
|
3
|
+
// the session JSON. This is what the app sees after the query string is parsed,
|
|
4
|
+
// so it is the right thing to hand to `URLSearchParams.set`, which does its own
|
|
5
|
+
// encoding.
|
|
6
|
+
export function sessionSpecParam(session) {
|
|
7
|
+
return `spec-${JSON.stringify(session)}`;
|
|
8
|
+
}
|
|
9
|
+
// The same value pre-encoded, for callers that concatenate a query string by
|
|
10
|
+
// hand rather than going through URL. Passing THIS to `URLSearchParams.set`
|
|
11
|
+
// encodes it a second time, and the app then decodes once and parses `%7B…` as
|
|
12
|
+
// JSON — so pick one path and stay on it. Shared so the runner and screenshot
|
|
13
|
+
// generator can't drift on the encoding.
|
|
14
|
+
export function encodeSessionSpec(session) {
|
|
15
|
+
return encodeURIComponent(sessionSpecParam(session));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The assembly a session spec's first view opens, if it names one.
|
|
19
|
+
*
|
|
20
|
+
* The session gate needs this because a spec can be loaded against any config —
|
|
21
|
+
* `--hub hg38 --session spec.json` where the spec opens a different assembly is
|
|
22
|
+
* legitimate, and defaulting the expectation to the hub name would fail a
|
|
23
|
+
* perfectly good capture.
|
|
24
|
+
*/
|
|
25
|
+
export function assemblyFromSession(session) {
|
|
26
|
+
const find = (views) => {
|
|
27
|
+
for (const view of views ?? []) {
|
|
28
|
+
const found = view.assembly ?? find(view.views);
|
|
29
|
+
if (found) {
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
};
|
|
35
|
+
return find(session.views);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Every trackId a session spec asks a view to open, including nested views (a
|
|
39
|
+
* synteny or breakpoint-split spec puts its LGVs one level down).
|
|
40
|
+
*
|
|
41
|
+
* Used as the session gate's expectation: it is what turns "the browser loaded"
|
|
42
|
+
* into "the tracks I named are open", and a spec is the one input where the
|
|
43
|
+
* caller has not spelled those ids out separately.
|
|
44
|
+
*/
|
|
45
|
+
export function trackIdsFromSession(session) {
|
|
46
|
+
const collect = (views) => (views ?? []).flatMap(view => [
|
|
47
|
+
// an entry may be a bare id or `{trackId, displaySnapshot}`; an object
|
|
48
|
+
// without a trackId is malformed rather than a track to expect, so it
|
|
49
|
+
// drops out here instead of gating on undefined
|
|
50
|
+
...(view.tracks ?? [])
|
|
51
|
+
.map(t => (typeof t === 'string' ? t : t.trackId))
|
|
52
|
+
.filter(id => id !== undefined),
|
|
53
|
+
...collect(view.views),
|
|
54
|
+
]);
|
|
55
|
+
return collect(session.views);
|
|
56
|
+
}
|
|
57
|
+
// Full `?config=…&session=…&sessionName=…` query string. `config` is inserted
|
|
58
|
+
// as-is (callers that point at a remote absolute URL pre-encode it themselves).
|
|
59
|
+
export function sessionSpecQuery({ config, session, sessionName = 'Screenshot', }) {
|
|
60
|
+
return `?config=${config}&session=${encodeSessionSpec(session)}&sessionName=${encodeURIComponent(sessionName)}`;
|
|
61
|
+
}
|