@jbrowse/capture 5.0.0-beta.1 → 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/README.md +82 -30
- package/esm/args.d.ts +8 -8
- package/esm/args.js +83 -71
- package/esm/bin.js +64 -56
- package/esm/browser.d.ts +13 -1
- package/esm/browser.js +17 -25
- package/esm/capture.d.ts +19 -87
- package/esm/capture.js +54 -237
- package/esm/catalog.d.ts +32 -0
- package/esm/catalog.js +84 -0
- package/esm/hub.d.ts +16 -15
- package/esm/hub.js +36 -29
- package/esm/imagePath.d.ts +1 -0
- package/esm/imagePath.js +8 -0
- package/esm/index.d.ts +12 -9
- package/esm/index.js +8 -7
- package/esm/instanceVersion.d.ts +5 -0
- package/esm/instanceVersion.js +21 -0
- package/esm/poll.d.ts +14 -0
- package/esm/poll.js +30 -0
- package/esm/ready.d.ts +29 -0
- package/esm/ready.js +44 -0
- package/esm/session.d.ts +24 -12
- package/esm/session.js +47 -32
- package/esm/sessionGate.d.ts +38 -105
- package/esm/sessionGate.js +82 -176
- package/esm/sessionOverflow.d.ts +6 -0
- package/esm/sessionOverflow.js +22 -0
- package/esm/url.d.ts +28 -17
- package/esm/url.js +51 -27
- package/esm/version.d.ts +1 -0
- package/esm/version.js +1 -0
- package/esm/waits.d.ts +23 -202
- package/esm/waits.js +77 -404
- package/package.json +3 -3
package/esm/browser.js
CHANGED
|
@@ -1,44 +1,36 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
8
|
+
/** Device pixel ratio. Default 2, the density a figure usually wants. */
|
|
72
9
|
deviceScaleFactor?: number;
|
|
73
|
-
|
|
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,
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
//
|
|
223
|
-
//
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/esm/catalog.d.ts
ADDED
|
@@ -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
|
+
}
|