@jbrowse/capture 5.0.0-beta.1 → 5.0.0-beta.3
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 +30 -10
- package/esm/args.js +15 -0
- package/esm/capture.d.ts +9 -66
- package/esm/capture.js +34 -192
- package/esm/hub.js +14 -1
- package/esm/index.d.ts +5 -3
- package/esm/index.js +3 -2
- package/esm/ready.d.ts +71 -0
- package/esm/ready.js +207 -0
- package/esm/session.js +10 -6
- package/esm/sessionGate.d.ts +12 -0
- package/esm/sessionGate.js +58 -7
- package/esm/waits.d.ts +29 -16
- package/esm/waits.js +54 -53
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -19,10 +19,14 @@ config, builds a session, resolves an assembly, fetches each track, and then
|
|
|
19
19
|
draws to a canvas — and a screenshot taken at any point before the last step is
|
|
20
20
|
a picture of an empty browser that looks like a successful run.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
`
|
|
22
|
+
Which signals exist at all depends on the build. Checked on 2026-09-05, the
|
|
23
|
+
default `--instance` — `jbrowse.org/code/jb2/latest`, the released build every
|
|
24
|
+
genomes.jbrowse.org link opens — publishes the loading overlay
|
|
25
|
+
(`[data-testid="loading-overlay"]`) and none of `data-app-phase`,
|
|
26
|
+
`data-view-phase`, `data-display-phase`, `data-display-drawn` or `data-busy`.
|
|
27
|
+
Every one of those is a **negative** signal — no overlay, no display in its
|
|
28
|
+
loading phase, no unpainted canvas — so all of them pass on a page whose
|
|
29
|
+
JavaScript has not started yet. Measured against that instance:
|
|
26
30
|
|
|
27
31
|
- `networkidle2` resolves at ~350ms
|
|
28
32
|
- the session appears at ~880ms
|
|
@@ -38,6 +42,18 @@ exists, its views are initialized, and the assembly and trackIds you asked for
|
|
|
38
42
|
are the ones actually open. A config URL that 404s, a trackId the config does
|
|
39
43
|
not define, and an assembly name that does not match all fail there, loudly.
|
|
40
44
|
|
|
45
|
+
What runs after that gate depends on which build answered it:
|
|
46
|
+
|
|
47
|
+
- **One that publishes `data-app-phase`** — `jbrowse.org/code/jb2/main`, or a
|
|
48
|
+
local build of this repo, reached with `--instance` — needs one selector and
|
|
49
|
+
no chain: `[data-app-phase="ready"]` appears when no view is resolving an
|
|
50
|
+
assembly and no display is fetching, and `data-display-drawn` then says paint
|
|
51
|
+
happened.
|
|
52
|
+
- **Every other build, the default included**, has the overlay and the status
|
|
53
|
+
text the session model carries per display. There the app has to be _seen
|
|
54
|
+
busy, then quiet for 2s_ — an absence alone is satisfied before the first
|
|
55
|
+
fetch is even set up — then given a fixed 1.5s for the paint nothing reports.
|
|
56
|
+
|
|
41
57
|
## Library
|
|
42
58
|
|
|
43
59
|
```js
|
|
@@ -69,7 +85,9 @@ Two waits, depending on what you did:
|
|
|
69
85
|
that is loading starts out `loading` and the transition into `ready` is it
|
|
70
86
|
finishing; a page you just clicked is already `ready` and stays that way until
|
|
71
87
|
the click's work registers, so waiting for `ready` there returns on the
|
|
72
|
-
pre-click frame. `waitForAppSettled` requires it to hold
|
|
88
|
+
pre-click frame. `waitForAppSettled` requires it to hold, and on a build
|
|
89
|
+
without the marker falls back to the quiet period rather than passing
|
|
90
|
+
instantly.
|
|
73
91
|
|
|
74
92
|
## Timeouts and unsettled waits
|
|
75
93
|
|
|
@@ -93,18 +111,20 @@ still tells you what did not settle.
|
|
|
93
111
|
|
|
94
112
|
## Reading the result
|
|
95
113
|
|
|
96
|
-
|
|
114
|
+
Four fields on a successful capture:
|
|
97
115
|
|
|
98
116
|
- **`unsettled`** — stages that hit their timeout. Empty unless you asked to
|
|
99
117
|
proceed anyway.
|
|
118
|
+
- **`appMarker`** — which of the two paths above ran. False on the default
|
|
119
|
+
instance, and on anything else that predates `data-app-phase`.
|
|
100
120
|
- **`pending`** — displays still reporting unpainted when the shutter fired. A
|
|
101
121
|
display can return to pending after its stage passed, so this is a separate
|
|
102
122
|
question from `unsettled`.
|
|
103
123
|
- **`paintContract`** — whether this JBrowse build publishes the per-display
|
|
104
|
-
paint attributes at all. It
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
paint attributes at all. It is false on the default instance, so `pending: []`
|
|
125
|
+
there means "cannot tell", not "all done", and the CLI says so. A page with no
|
|
126
|
+
tracks open reports true — there is nothing to measure, which is not the same
|
|
127
|
+
as being unable to.
|
|
108
128
|
|
|
109
129
|
## CLI
|
|
110
130
|
|
package/esm/args.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const NUMERIC = new Set(['width', 'height', 'scale', 'timeout', 'settle']);
|
|
2
|
+
const POSITIVE = new Set(['width', 'height', 'scale', 'timeout']);
|
|
3
|
+
const NON_NEGATIVE = new Set(['settle']);
|
|
2
4
|
const FLAGS = new Set([
|
|
3
5
|
'fullPage',
|
|
4
6
|
'headed',
|
|
@@ -53,6 +55,10 @@ export function parseArgs(argv) {
|
|
|
53
55
|
throw new Error(`unknown flag "${arg}"`);
|
|
54
56
|
}
|
|
55
57
|
if (FLAGS.has(name)) {
|
|
58
|
+
// `--fullPage=false` used to set the flag true, silently.
|
|
59
|
+
if (eq !== -1) {
|
|
60
|
+
throw new Error(`--${name} is a flag and takes no value; omit it to leave it off`);
|
|
61
|
+
}
|
|
56
62
|
out[name] = true;
|
|
57
63
|
continue;
|
|
58
64
|
}
|
|
@@ -69,6 +75,15 @@ export function parseArgs(argv) {
|
|
|
69
75
|
if (!Number.isFinite(n)) {
|
|
70
76
|
throw new Error(`--${name} needs a number, got "${value}"`);
|
|
71
77
|
}
|
|
78
|
+
// Otherwise these fail much later and elsewhere: a zero size inside
|
|
79
|
+
// puppeteer, naming neither the flag nor the value, and `--timeout 0` as
|
|
80
|
+
// no timeout at all there while the node-polled waits read it as expired.
|
|
81
|
+
if (POSITIVE.has(name) && n <= 0) {
|
|
82
|
+
throw new Error(`--${name} needs a positive number, got "${value}"`);
|
|
83
|
+
}
|
|
84
|
+
if (NON_NEGATIVE.has(name) && n < 0) {
|
|
85
|
+
throw new Error(`--${name} needs a number of milliseconds that is zero or more, got "${value}"`);
|
|
86
|
+
}
|
|
72
87
|
out[name] = n;
|
|
73
88
|
}
|
|
74
89
|
else {
|
package/esm/capture.d.ts
CHANGED
|
@@ -1,70 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReadyOptions, ReadyReport } from './ready.ts';
|
|
2
2
|
import type { JBrowseUrlOptions } from './url.ts';
|
|
3
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
4
|
export interface OpenOptions extends JBrowseUrlOptions, ReadyOptions {
|
|
69
5
|
width?: number;
|
|
70
6
|
height?: number;
|
|
@@ -99,7 +35,14 @@ export declare function openJBrowse(options?: OpenOptions): Promise<OpenResult>;
|
|
|
99
35
|
export interface CaptureOptions extends OpenOptions {
|
|
100
36
|
/** PNG path to write. Omit to get the buffer back and write it yourself. */
|
|
101
37
|
out?: string;
|
|
102
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Capture the whole scrollable page rather than the viewport. Implemented by
|
|
40
|
+
* growing the viewport to the page height and re-settling, never by
|
|
41
|
+
* `page.screenshot({ fullPage: true })` — puppeteer implements that flag with
|
|
42
|
+
* the same viewport resize but shoots immediately, and the capture can return
|
|
43
|
+
* before the content re-rasters (measured in the browser-test suites as a
|
|
44
|
+
* 10–25% image diff that moves run to run).
|
|
45
|
+
*/
|
|
103
46
|
fullPage?: boolean;
|
|
104
47
|
}
|
|
105
48
|
export interface CaptureResult extends ReadyReport {
|
package/esm/capture.js
CHANGED
|
@@ -1,184 +1,11 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
1
3
|
import puppeteer from 'puppeteer';
|
|
2
4
|
import { BASE_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.js';
|
|
5
|
+
import { waitForJBrowseReady } from './ready.js';
|
|
3
6
|
import { assemblyFromSession, trackIdsFromSession } from './session.js';
|
|
4
|
-
import { describePendingDisplays, hasPaintContract, pendingDisplayStates, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
|
|
5
7
|
import { jbrowseUrl } from './url.js';
|
|
6
|
-
import {
|
|
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
|
-
}
|
|
8
|
+
import { waitForAppSettled } from './waits.js';
|
|
182
9
|
/**
|
|
183
10
|
* Launch a browser, navigate to a JBrowse session, and wait until it has
|
|
184
11
|
* rendered. The caller owns the returned browser and must close it.
|
|
@@ -192,11 +19,11 @@ function unsettledMessage(timeout, unsettled, pending) {
|
|
|
192
19
|
* than producing an image of an empty browser.
|
|
193
20
|
*/
|
|
194
21
|
export async function openJBrowse(options = {}) {
|
|
195
|
-
const { width = 1400, height = 900, deviceScaleFactor = 2, headless = true, executablePath = findChromeExecutable(), args = [], onConsole,
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
22
|
+
const { width = 1400, height = 900, deviceScaleFactor = 2, headless = true, executablePath = findChromeExecutable(), args = [], onConsole,
|
|
23
|
+
// Defaulted here as well as in waitForJBrowseReady, so the navigation gets
|
|
24
|
+
// the same budget as the wait stages — puppeteer's own goto default is 30s,
|
|
25
|
+
// half of what a caller passing nothing was told each stage would get.
|
|
26
|
+
timeout = 60000, trackIds,
|
|
200
27
|
// `assembly` is deliberately NOT pulled out here: it is both a URL option
|
|
201
28
|
// (which assembly to open) and the session gate's expectation (which
|
|
202
29
|
// assembly must end up open), and destructuring it for the second use would
|
|
@@ -229,18 +56,17 @@ export async function openJBrowse(options = {}) {
|
|
|
229
56
|
// construction. Listing them by hand is what dropped `allowUnsettled`:
|
|
230
57
|
// it was declared, documented, recommended by this function's own timeout
|
|
231
58
|
// message, and silently never forwarded, so `--allowUnsettled` did
|
|
232
|
-
// nothing and a timing-out stage always threw.
|
|
233
|
-
// derived from the URL
|
|
59
|
+
// nothing and a timing-out stage always threw. Overridden below: the
|
|
60
|
+
// defaulted timeout, and the two options derived from the URL.
|
|
234
61
|
...options,
|
|
62
|
+
timeout,
|
|
235
63
|
// A session spec's own assembly wins over the hub name. `--hub hg38
|
|
236
64
|
// --session spec.json` where the spec opens something else is legitimate
|
|
237
65
|
// (the hub is just supplying the config), and expecting the hub name there
|
|
238
66
|
// would fail a capture that is entirely correct.
|
|
239
|
-
assembly: urlOptions.
|
|
240
|
-
(urlOptions.session
|
|
241
|
-
|
|
242
|
-
: undefined) ??
|
|
243
|
-
(urlOptions.session ? undefined : urlOptions.hub),
|
|
67
|
+
assembly: urlOptions.session
|
|
68
|
+
? (urlOptions.assembly ?? assemblyFromSession(urlOptions.session))
|
|
69
|
+
: (urlOptions.assembly ?? urlOptions.hub),
|
|
244
70
|
trackIds: trackIds ??
|
|
245
71
|
urlOptions.tracks ??
|
|
246
72
|
(urlOptions.session
|
|
@@ -251,8 +77,9 @@ export async function openJBrowse(options = {}) {
|
|
|
251
77
|
}
|
|
252
78
|
catch (error) {
|
|
253
79
|
// 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
|
-
|
|
80
|
+
// process; without this the caller has no handle to close it. The close is
|
|
81
|
+
// guarded so its own failure cannot replace the error worth reporting.
|
|
82
|
+
await browser.close().catch(() => { });
|
|
256
83
|
throw error;
|
|
257
84
|
}
|
|
258
85
|
}
|
|
@@ -262,9 +89,24 @@ export async function openJBrowse(options = {}) {
|
|
|
262
89
|
*/
|
|
263
90
|
export async function captureJBrowse(options = {}) {
|
|
264
91
|
const { out, fullPage = false, ...openOptions } = options;
|
|
92
|
+
if (out) {
|
|
93
|
+
// Before the browser launches: a missing parent directory otherwise fails
|
|
94
|
+
// at the screenshot, after the whole launch-navigate-wait cycle.
|
|
95
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
96
|
+
}
|
|
265
97
|
const { browser, page, url, ...report } = await openJBrowse(openOptions);
|
|
266
98
|
try {
|
|
267
|
-
|
|
99
|
+
if (fullPage) {
|
|
100
|
+
const viewport = page.viewport();
|
|
101
|
+
const pageHeight = await page.evaluate(() => Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)));
|
|
102
|
+
if (viewport && pageHeight > viewport.height) {
|
|
103
|
+
await page.setViewport({ ...viewport, height: pageHeight });
|
|
104
|
+
// The resize invalidates the raster and can start work (a display that
|
|
105
|
+
// grew gained rows to draw), so the frame has to settle again.
|
|
106
|
+
await waitForAppSettled(page, { timeout: openOptions.timeout });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const image = await page.screenshot({ path: out });
|
|
268
110
|
return { url, image, ...report };
|
|
269
111
|
}
|
|
270
112
|
finally {
|
package/esm/hub.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const HUB_HOST = 'https://jbrowse.org';
|
|
2
|
+
const HUB_FETCH_TIMEOUT_MS = 30000;
|
|
2
3
|
// The genomes.jbrowse.org URL scheme: a UCSC database name (hg38, mm10, ...)
|
|
3
4
|
// maps to /ucsc/<db>/config.json; a GenArk accession (GCA_/GCF_...) fans its 9
|
|
4
5
|
// digits out into a 3-level directory tree.
|
|
@@ -22,7 +23,19 @@ export function hubUrl(hub) {
|
|
|
22
23
|
*/
|
|
23
24
|
export async function fetchHubConfig(hub) {
|
|
24
25
|
const url = hubUrl(hub);
|
|
25
|
-
|
|
26
|
+
// Without the signal a stalled connection hangs `jb2capture list` forever:
|
|
27
|
+
// fetch has no timeout of its own, and this is the one request the tool makes
|
|
28
|
+
// outside puppeteer's budget.
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS),
|
|
31
|
+
}).catch((error) => {
|
|
32
|
+
// The budget is only part of the diagnosis when it is what fired; naming it
|
|
33
|
+
// on a DNS failure reads as though the request had been given 30s to resolve.
|
|
34
|
+
const timedOut = error instanceof Error && error.name === 'TimeoutError';
|
|
35
|
+
throw new Error(`hub "${hub}" could not be fetched from ${url} ` +
|
|
36
|
+
`(${error instanceof Error ? error.message : error}${timedOut ? `, after ${HUB_FETCH_TIMEOUT_MS}ms` : ''}). ` +
|
|
37
|
+
'See https://genomes.jbrowse.org for the available assemblies.');
|
|
38
|
+
});
|
|
26
39
|
if (!res.ok) {
|
|
27
40
|
throw new Error(`hub "${hub}" not found (HTTP ${res.status} from ${url}). ` +
|
|
28
41
|
'See https://genomes.jbrowse.org for the available assemblies.');
|
package/esm/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export { captureJBrowse, openJBrowse
|
|
2
|
-
export {
|
|
1
|
+
export { captureJBrowse, openJBrowse } from './capture.ts';
|
|
2
|
+
export { waitForJBrowseReady } from './ready.ts';
|
|
3
|
+
export { APP_CENSUS, PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.ts';
|
|
3
4
|
export { PUBLIC_INSTANCE, jbrowseUrl } from './url.ts';
|
|
4
5
|
export { fetchHubConfig, hubUrl, listHubTracks } from './hub.ts';
|
|
5
6
|
export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.ts';
|
|
6
7
|
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
8
|
export { BASE_CHROME_ARGS, SANDBOX_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.ts';
|
|
8
|
-
export type { CaptureOptions, CaptureResult, OpenOptions, OpenResult,
|
|
9
|
+
export type { CaptureOptions, CaptureResult, OpenOptions, OpenResult, } from './capture.ts';
|
|
10
|
+
export type { ReadyOptions, ReadyReport } from './ready.ts';
|
|
9
11
|
export type { Instrumentation, PendingDisplay, SessionExpectations, SessionSummary, } from './sessionGate.ts';
|
|
10
12
|
export type { JBrowseUrlOptions } from './url.ts';
|
package/esm/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { captureJBrowse, openJBrowse
|
|
2
|
-
export {
|
|
1
|
+
export { captureJBrowse, openJBrowse } from './capture.js';
|
|
2
|
+
export { waitForJBrowseReady } from './ready.js';
|
|
3
|
+
export { APP_CENSUS, PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
|
|
3
4
|
export { PUBLIC_INSTANCE, jbrowseUrl } from './url.js';
|
|
4
5
|
export { fetchHubConfig, hubUrl, listHubTracks } from './hub.js';
|
|
5
6
|
export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.js';
|
package/esm/ready.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Instrumentation, PendingDisplay, SessionExpectations } from './sessionGate.ts';
|
|
2
|
+
import type { Page } from 'puppeteer';
|
|
3
|
+
export interface ReadyOptions extends SessionExpectations {
|
|
4
|
+
/** Budget for each individual wait stage. */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Also wait out the app's own busy reports — adapter "Downloading…" status
|
|
8
|
+
* text, which can outlive the loading overlay when a track streams a large
|
|
9
|
+
* remote file, and any "Loading…/Rendering…" label still on screen. One
|
|
10
|
+
* predicate answers both. Only consulted on a build without the app-ready
|
|
11
|
+
* marker; the marker's `ready` already means no display is fetching.
|
|
12
|
+
*/
|
|
13
|
+
waitForDownloads?: boolean;
|
|
14
|
+
/** Extra settle after everything reports done, for animations and tooltips. */
|
|
15
|
+
settleMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Skip the session gate. Only for a page that is not jbrowse-web and so
|
|
18
|
+
* publishes no `window.JBrowseSession` — an embedded component, say. You lose
|
|
19
|
+
* the only check that the data you asked for is the data on screen, and the
|
|
20
|
+
* marker and instrumentation reads then race the boot, so the chain leans on
|
|
21
|
+
* the seen-busy-then-quiet gate instead of the attributes.
|
|
22
|
+
*/
|
|
23
|
+
expectSession?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Return the report instead of throwing when a wait stage times out. For
|
|
26
|
+
* capturing a page that is deliberately mid-load, or accepting a known-slow
|
|
27
|
+
* one. The unsettled stages are still listed in the report either way.
|
|
28
|
+
*/
|
|
29
|
+
allowUnsettled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ReadyReport {
|
|
32
|
+
/** Displays still reporting unpainted at the end of the wait. */
|
|
33
|
+
pending: string[];
|
|
34
|
+
/**
|
|
35
|
+
* The same displays with the phase each one publishes, which is what
|
|
36
|
+
* separates a slow fetch from a display that says it finished without
|
|
37
|
+
* painting. Empty whenever `pending` is.
|
|
38
|
+
*/
|
|
39
|
+
pendingStates: PendingDisplay[];
|
|
40
|
+
/**
|
|
41
|
+
* Whether every display's paint state was actually measurable — false when
|
|
42
|
+
* tracks are open but the build publishes no `data-display-drawn`. With this
|
|
43
|
+
* false, an empty `pending` means "cannot tell", not "all done".
|
|
44
|
+
*/
|
|
45
|
+
paintContract: boolean;
|
|
46
|
+
/** Wait stages that hit their timeout instead of being satisfied. */
|
|
47
|
+
unsettled: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Which readiness attributes the build published, read once the session was
|
|
50
|
+
* up. Every `false` here is a wait that could not fail rather than one that
|
|
51
|
+
* passed, and the chain compensates for it — see LEGACY_QUIET_MS.
|
|
52
|
+
*/
|
|
53
|
+
instrumentation: Instrumentation;
|
|
54
|
+
/**
|
|
55
|
+
* Whether the page published `[data-app-phase]`, the one positive readiness
|
|
56
|
+
* selector. True means the wait was that selector and nothing else; false
|
|
57
|
+
* means the build predates it and the fallback chain ran.
|
|
58
|
+
*/
|
|
59
|
+
appMarker: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Wait until a JBrowse page has finished loading AND finished drawing.
|
|
63
|
+
*
|
|
64
|
+
* One positive gate, then the negative ones. The order is the whole point: the
|
|
65
|
+
* DOM waits all pass on a page that has not started yet, so without the session
|
|
66
|
+
* gate in front the chain returns in under a second (see sessionGate.ts). After
|
|
67
|
+
* it, each stage is only meaningful once the previous has passed — a view still
|
|
68
|
+
* resolving its assembly has mounted no displays, and a display that has mounted
|
|
69
|
+
* but not fetched has nothing to be drawn yet.
|
|
70
|
+
*/
|
|
71
|
+
export declare function waitForJBrowseReady(page: Page, { timeout, waitForDownloads, settleMs, expectSession, assembly, trackIds, allowUnsettled, }?: ReadyOptions): Promise<ReadyReport>;
|
package/esm/ready.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describePendingDisplays, hasPaintContract, pendingDisplayStates, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
|
|
2
|
+
import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
|
|
3
|
+
// The whole ready chain, in a module that touches puppeteer only through its
|
|
4
|
+
// types — capture.ts holds the runtime import (launching a browser), which is
|
|
5
|
+
// what keeps the chain callable under jest against a stubbed Page.
|
|
6
|
+
// A build with no per-display paint attributes gives no signal between "the
|
|
7
|
+
// overlay cleared" and "the canvas has pixels on it", so the only thing left is
|
|
8
|
+
// to wait. Sized from the measured gap on jbrowse.org/code/jb2/latest between
|
|
9
|
+
// the overlay clearing and the tracks being fully drawn.
|
|
10
|
+
const LEGACY_PAINT_SETTLE_MS = 1500;
|
|
11
|
+
// ...and the gap at the OTHER end, which is the one that lands a capture on an
|
|
12
|
+
// app that has drawn nothing at all. Measured on the same instance with two
|
|
13
|
+
// remote tracks: the session gate is satisfied at ~2.5s, and the loading overlay
|
|
14
|
+
// — the only remaining signal there — does not go up until ~3.5s. An
|
|
15
|
+
// instrumented build has no such window: `data-display-phase` appears in the
|
|
16
|
+
// same frame as the display it belongs to.
|
|
17
|
+
//
|
|
18
|
+
// So on an uninstrumented build the app has to be seen to START working, and
|
|
19
|
+
// then hold still, before it counts as finished. The window is how long the
|
|
20
|
+
// first indicator gets to appear before "nothing to wait for" is the better
|
|
21
|
+
// reading; the quiet is how long the idle then has to hold.
|
|
22
|
+
const LEGACY_BUSY_WINDOW_MS = 4000;
|
|
23
|
+
const LEGACY_QUIET_MS = 2000;
|
|
24
|
+
/**
|
|
25
|
+
* Wait until a JBrowse page has finished loading AND finished drawing.
|
|
26
|
+
*
|
|
27
|
+
* One positive gate, then the negative ones. The order is the whole point: the
|
|
28
|
+
* DOM waits all pass on a page that has not started yet, so without the session
|
|
29
|
+
* gate in front the chain returns in under a second (see sessionGate.ts). After
|
|
30
|
+
* it, each stage is only meaningful once the previous has passed — a view still
|
|
31
|
+
* resolving its assembly has mounted no displays, and a display that has mounted
|
|
32
|
+
* but not fetched has nothing to be drawn yet.
|
|
33
|
+
*/
|
|
34
|
+
export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownloads = true, settleMs = 0, expectSession = true, assembly, trackIds, allowUnsettled = false, } = {}) {
|
|
35
|
+
const unsettled = [];
|
|
36
|
+
const stage = async (name, work) => {
|
|
37
|
+
if (!(await work)) {
|
|
38
|
+
unsettled.push(name);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
// The post-condition of the paint stage, and on a terminal display the ONLY
|
|
42
|
+
// report of it: `waitForDisplaysDone` returns as soon as every pending display
|
|
43
|
+
// is past `loading`, so an errored canvas that will never paint no longer
|
|
44
|
+
// spends the whole timeout to arrive here. The phase is what a reader needs —
|
|
45
|
+
// `error` is a banner, `ready` is a display claiming it finished without
|
|
46
|
+
// drawing — so the entry carries the census rather than naming the question.
|
|
47
|
+
const reportUnpainted = async () => {
|
|
48
|
+
const pending = await pendingDisplayStates(page);
|
|
49
|
+
if (pending.length > 0) {
|
|
50
|
+
unsettled.push(`display(s) never painted: ${describePendingDisplays(pending)}`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
// The two hard waits reject with puppeteer's own `Waiting failed: Nms
|
|
54
|
+
// exceeded`, which names neither the stage nor the selector — the whole
|
|
55
|
+
// failure mode this module exists to avoid, arriving as an error message
|
|
56
|
+
// instead of as a blank image. Say which gate it was and what to do.
|
|
57
|
+
const required = async (name, work) => {
|
|
58
|
+
try {
|
|
59
|
+
return await work;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`gave up waiting after ${timeout}ms: ${name}. Raise the timeout if the ` +
|
|
63
|
+
'page is merely slow; if it never finishes, open the same URL in a ' +
|
|
64
|
+
'browser — this gate has no content to fall through to.');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// 0. the session exists and holds what was asked for. Positive, and throws.
|
|
68
|
+
if (expectSession) {
|
|
69
|
+
await waitForSession(page, { assembly, trackIds, timeout });
|
|
70
|
+
}
|
|
71
|
+
// 1. THE WHOLE ANSWER, on any build that has it: the session renders
|
|
72
|
+
// `[data-app-phase="ready"]` when no view is resolving an assembly and no
|
|
73
|
+
// display is fetching. It is positive, so unlike everything below it
|
|
74
|
+
// cannot be satisfied by an app that has not started.
|
|
75
|
+
//
|
|
76
|
+
// Held, not read once — the same hold `jb.waitReady` requires, because a
|
|
77
|
+
// display drops to `ready` in the gap between one fetch finishing and the
|
|
78
|
+
// debounced next one starting. The marker is known to exist here, so
|
|
79
|
+
// `waitForAppSettled`'s quiet-period fallback does not come into it.
|
|
80
|
+
//
|
|
81
|
+
// Everything after this point is the fallback for a deployment older than
|
|
82
|
+
// the marker, and can be deleted the day the oldest supported build has
|
|
83
|
+
// it.
|
|
84
|
+
if (await hasAppReadyMarker(page)) {
|
|
85
|
+
const ready = await waitForAppSettled(page, { timeout });
|
|
86
|
+
if (!ready) {
|
|
87
|
+
unsettled.push('the app never held itself ready');
|
|
88
|
+
}
|
|
89
|
+
// One thing the marker does not answer, so this stage stays even here: the
|
|
90
|
+
// marker is about WORK, and a display whose fetch failed is not working. It
|
|
91
|
+
// reads `ready` over an error banner, which is a correct answer to a
|
|
92
|
+
// different question than a capture is asking. `data-display-drawn` is the
|
|
93
|
+
// stricter gate — the two comparative canvases publish it from `settled`,
|
|
94
|
+
// which holds an error open deliberately so a golden regenerated during an
|
|
95
|
+
// outage fails on the census below instead of absorbing the banner as
|
|
96
|
+
// expected output. Ordered after the marker rather than instead of it, which
|
|
97
|
+
// is what makes an absence meaningful (see waitForDisplaysDone), and free on
|
|
98
|
+
// a page that has no such canvas. Skipped when the marker itself timed out:
|
|
99
|
+
// the paint wait would spend a second full timeout on a page already known
|
|
100
|
+
// unsettled, and the census reports the unpainted displays either way.
|
|
101
|
+
if (ready) {
|
|
102
|
+
await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
|
|
103
|
+
}
|
|
104
|
+
await reportUnpainted();
|
|
105
|
+
if (settleMs > 0) {
|
|
106
|
+
await delay(settleMs);
|
|
107
|
+
}
|
|
108
|
+
const pendingStates = await pendingDisplayStates(page);
|
|
109
|
+
const report = {
|
|
110
|
+
pending: pendingStates.map(d => d.name),
|
|
111
|
+
pendingStates,
|
|
112
|
+
paintContract: await hasPaintContract(page),
|
|
113
|
+
unsettled,
|
|
114
|
+
instrumentation: await readInstrumentation(page),
|
|
115
|
+
appMarker: true,
|
|
116
|
+
};
|
|
117
|
+
if (!allowUnsettled && unsettled.length > 0) {
|
|
118
|
+
throw new Error(unsettledMessage(timeout, unsettled));
|
|
119
|
+
}
|
|
120
|
+
return report;
|
|
121
|
+
}
|
|
122
|
+
// 1b. what an older build can be asked instead. On one that publishes none of
|
|
123
|
+
// these, the stages below are not gates at all: they are assertions about
|
|
124
|
+
// absent attributes that no page can fail.
|
|
125
|
+
const instrumentation = await readInstrumentation(page);
|
|
126
|
+
const instrumented = instrumentation.displayPhase || instrumentation.displayDrawn;
|
|
127
|
+
// A page with no tracks open has nothing to load, so the quiet gate below
|
|
128
|
+
// would be a fixed sleep for an import form or a menu shot. No summary at all
|
|
129
|
+
// — expectSession: false, where these reads race the boot — is the opposite
|
|
130
|
+
// case: nothing has been ruled out, and reading "unknown" as "no tracks" here
|
|
131
|
+
// is what let the whole chain pass over an app that had not started, since
|
|
132
|
+
// every stage below the gate is an absence an empty page satisfies.
|
|
133
|
+
const summary = await readSessionSummary(page);
|
|
134
|
+
const needsQuietGate = !instrumented && (summary === undefined || summary.trackIds.length > 0);
|
|
135
|
+
// 2. the view has an assembly and its React component has arrived. Also not
|
|
136
|
+
// best-effort: a view stuck here has no content to fall through to.
|
|
137
|
+
await required('a view never left its loading phase (still resolving its assembly, or its ' +
|
|
138
|
+
'lazily-imported component never arrived)', waitForViewPhases(page, timeout));
|
|
139
|
+
// 3. no track is still fetching: an overlay that never clears is a fetch
|
|
140
|
+
// that never finished, with nothing behind it, so this one throws too.
|
|
141
|
+
await required('the loading overlay never cleared (a track fetch never finished)', waitForLoadingComplete(page, { timeout }));
|
|
142
|
+
await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
|
|
143
|
+
// 4. no display is still pending its first paint
|
|
144
|
+
await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
|
|
145
|
+
await reportUnpainted();
|
|
146
|
+
// 5. nothing still reports itself busy: adapter "Downloading…" status on the
|
|
147
|
+
// session model, which can outlive the overlay, and the visible
|
|
148
|
+
// "Loading…/Rendering…" labels of the views that publish no phase
|
|
149
|
+
// attribute. One predicate answers both, so it is one stage — it used to
|
|
150
|
+
// run twice, once as waitForLoadingComplete's download half and once
|
|
151
|
+
// under its own name, and a slow page paid the same timeout for each.
|
|
152
|
+
if (waitForDownloads) {
|
|
153
|
+
await stage('a track was still downloading, or a "Loading…/Rendering…" label was ' +
|
|
154
|
+
'still on screen', waitForQuiescent(page, { timeout }));
|
|
155
|
+
}
|
|
156
|
+
// 6. and on a build that answered none of the above, the one gate that does
|
|
157
|
+
// not depend on an attribute existing: the app has to hold still. Every
|
|
158
|
+
// stage before this one passed the moment it was asked, so without it the
|
|
159
|
+
// chain returns while the first fetch is still being set up.
|
|
160
|
+
if (needsQuietGate) {
|
|
161
|
+
await stage('the app never went quiet for ' +
|
|
162
|
+
`${LEGACY_QUIET_MS}ms (this build publishes no readiness attributes, ` +
|
|
163
|
+
'so being seen to work and then stop is the only finished signal there ' +
|
|
164
|
+
'is)', waitForQuietPeriod(page, {
|
|
165
|
+
quietMs: LEGACY_QUIET_MS,
|
|
166
|
+
busyWindowMs: LEGACY_BUSY_WINDOW_MS,
|
|
167
|
+
timeout,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
const paintContract = await hasPaintContract(page);
|
|
171
|
+
if (!paintContract) {
|
|
172
|
+
await delay(LEGACY_PAINT_SETTLE_MS);
|
|
173
|
+
}
|
|
174
|
+
if (settleMs > 0) {
|
|
175
|
+
await delay(settleMs);
|
|
176
|
+
}
|
|
177
|
+
const pendingStates = await pendingDisplayStates(page);
|
|
178
|
+
const report = {
|
|
179
|
+
pending: pendingStates.map(d => d.name),
|
|
180
|
+
pendingStates,
|
|
181
|
+
paintContract,
|
|
182
|
+
unsettled,
|
|
183
|
+
instrumentation,
|
|
184
|
+
appMarker: false,
|
|
185
|
+
};
|
|
186
|
+
if (!allowUnsettled && unsettled.length > 0) {
|
|
187
|
+
// Throwing is the point. Each of these stages swallows its own timeout so a
|
|
188
|
+
// slow page is not failed for being slow, which historically meant the run
|
|
189
|
+
// ended with an image and an exit code of 0 whether it had settled or not.
|
|
190
|
+
// A caller that genuinely wants the frame anyway asks for it by name.
|
|
191
|
+
throw new Error(unsettledMessage(timeout, unsettled));
|
|
192
|
+
}
|
|
193
|
+
return report;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* What a timed-out wait says. A stage name is the QUESTION rather than the
|
|
197
|
+
* answer — "a display never reported its first paint" reads identically for a
|
|
198
|
+
* slow fetch, a failed one and a display that never had a canvas to paint — so
|
|
199
|
+
* `reportUnpainted` puts the census in the list beside it, and the `ready` case
|
|
200
|
+
* is the one that most changes what a reader does next: a longer timeout is the
|
|
201
|
+
* fix for `loading` and never the fix for that.
|
|
202
|
+
*/
|
|
203
|
+
function unsettledMessage(timeout, unsettled) {
|
|
204
|
+
return (`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ` +
|
|
205
|
+
'Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ' +
|
|
206
|
+
'capture the frame as it stands.');
|
|
207
|
+
}
|
package/esm/session.js
CHANGED
|
@@ -43,13 +43,17 @@ export function assemblyFromSession(session) {
|
|
|
43
43
|
* caller has not spelled those ids out separately.
|
|
44
44
|
*/
|
|
45
45
|
export function trackIdsFromSession(session) {
|
|
46
|
+
// an entry may be a bare id or `{trackId, displaySnapshot}`; an object
|
|
47
|
+
// without a trackId is malformed rather than a track to expect, so it drops
|
|
48
|
+
// out here instead of gating on undefined
|
|
49
|
+
const idsOf = (tracks) => (tracks ?? [])
|
|
50
|
+
.map(t => (typeof t === 'string' ? t : t.trackId))
|
|
51
|
+
.filter(id => id !== undefined);
|
|
46
52
|
const collect = (views) => (views ?? []).flatMap(view => [
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
...(view.
|
|
51
|
-
.map(t => (typeof t === 'string' ? t : t.trackId))
|
|
52
|
-
.filter(id => id !== undefined),
|
|
53
|
+
// a nested entry is a synteny spec's per-level list; these used to be
|
|
54
|
+
// filtered out as malformed, so a synteny --session gated on nothing
|
|
55
|
+
...(view.tracks ?? []).flatMap(t => Array.isArray(t) ? idsOf(t) : idsOf([t])),
|
|
56
|
+
...(view.levels ?? []).flatMap(l => idsOf(l.tracks)),
|
|
53
57
|
...collect(view.views),
|
|
54
58
|
]);
|
|
55
59
|
return collect(session.views);
|
package/esm/sessionGate.d.ts
CHANGED
|
@@ -10,6 +10,18 @@ export interface SessionExpectations {
|
|
|
10
10
|
*/
|
|
11
11
|
trackIds?: string[];
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The census the app publishes beside its phase: `AppReadyMarker` renders
|
|
15
|
+
* `data-app-views` (a count), and `data-app-assemblies` / `data-app-tracks`
|
|
16
|
+
* (JSON string arrays of what is open). On a build that has it, the session
|
|
17
|
+
* gate and the summary are a read of ONE element — no walk of the session
|
|
18
|
+
* model, so nothing here has to know which property a container view keeps its
|
|
19
|
+
* children on. The session walk below each census read is the fallback for a
|
|
20
|
+
* deployed build older than the census, and goes when the legacy chain does.
|
|
21
|
+
* `scripts/readinessContract.test.ts` pins the attribute names against the
|
|
22
|
+
* marker, since neither package may import the other.
|
|
23
|
+
*/
|
|
24
|
+
export declare const APP_CENSUS = "[data-app-tracks]";
|
|
13
25
|
export interface SessionSummary {
|
|
14
26
|
views: number;
|
|
15
27
|
assemblies: string[];
|
package/esm/sessionGate.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
// `window.JBrowseSession` is jbrowse-web's own devtools/automation handle
|
|
2
|
+
// (products/jbrowse-web/src/components/JBrowse.tsx). It is declared `unknown`
|
|
3
|
+
// there and is a live MST node here, so a cast at the page boundary is
|
|
4
|
+
// unavoidable. It is repeated in each function below rather than shared: every
|
|
5
|
+
// one of them is serialized into the page by puppeteer, so it can only call what
|
|
6
|
+
// it declares inside itself.
|
|
7
|
+
/**
|
|
8
|
+
* The census the app publishes beside its phase: `AppReadyMarker` renders
|
|
9
|
+
* `data-app-views` (a count), and `data-app-assemblies` / `data-app-tracks`
|
|
10
|
+
* (JSON string arrays of what is open). On a build that has it, the session
|
|
11
|
+
* gate and the summary are a read of ONE element — no walk of the session
|
|
12
|
+
* model, so nothing here has to know which property a container view keeps its
|
|
13
|
+
* children on. The session walk below each census read is the fallback for a
|
|
14
|
+
* deployed build older than the census, and goes when the legacy chain does.
|
|
15
|
+
* `scripts/readinessContract.test.ts` pins the attribute names against the
|
|
16
|
+
* marker, since neither package may import the other.
|
|
17
|
+
*/
|
|
18
|
+
export const APP_CENSUS = '[data-app-tracks]';
|
|
1
19
|
/** What the page currently has open, or undefined if there is no session yet. */
|
|
2
20
|
export function readSessionSummary(page) {
|
|
3
21
|
return page.evaluate(readSessionSummaryInPage);
|
|
@@ -9,6 +27,20 @@ export function readSessionSummary(page) {
|
|
|
9
27
|
// so calling it in node against a stubbed global exercises the very function
|
|
10
28
|
// puppeteer serializes, rather than a copy of it that can drift.
|
|
11
29
|
export function readSessionSummaryInPage() {
|
|
30
|
+
// census first: one element, no walk (see APP_CENSUS)
|
|
31
|
+
const marker = document.querySelector('[data-app-tracks]');
|
|
32
|
+
if (marker) {
|
|
33
|
+
try {
|
|
34
|
+
return {
|
|
35
|
+
views: Number(marker.dataset.appViews) || 0,
|
|
36
|
+
assemblies: JSON.parse(marker.dataset.appAssemblies ?? '[]'),
|
|
37
|
+
trackIds: JSON.parse(marker.dataset.appTracks ?? '[]'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// a malformed census falls through to the walk rather than failing the read
|
|
42
|
+
}
|
|
43
|
+
}
|
|
12
44
|
const session = globalThis.JBrowseSession;
|
|
13
45
|
const views = session?.views;
|
|
14
46
|
if (!views) {
|
|
@@ -19,7 +51,7 @@ export function readSessionSummaryInPage() {
|
|
|
19
51
|
function collect(v) {
|
|
20
52
|
return [
|
|
21
53
|
...(v.tracks ?? []),
|
|
22
|
-
...(v.levels ?? []).flatMap(l => l.tracks ?? []),
|
|
54
|
+
...(v.trackContainers ?? v.levels ?? []).flatMap(l => l.tracks ?? []),
|
|
23
55
|
...(v.views ?? []).flatMap(collect),
|
|
24
56
|
];
|
|
25
57
|
}
|
|
@@ -47,6 +79,24 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
|
|
|
47
79
|
try {
|
|
48
80
|
// #region session-gate
|
|
49
81
|
await page.waitForFunction((wantAssembly, wantTracks) => {
|
|
82
|
+
// The census the app publishes on its ready marker (see APP_CENSUS):
|
|
83
|
+
// on a build that has it, the whole gate is a read of one element.
|
|
84
|
+
const marker = document.querySelector('[data-app-tracks]');
|
|
85
|
+
if (marker) {
|
|
86
|
+
try {
|
|
87
|
+
const openViews = Number(marker.dataset.appViews);
|
|
88
|
+
const assemblies = JSON.parse(marker.dataset.appAssemblies ?? '[]');
|
|
89
|
+
const openTracks = JSON.parse(marker.dataset.appTracks ?? '[]');
|
|
90
|
+
return (openViews > 0 &&
|
|
91
|
+
(wantAssembly === null || assemblies.includes(wantAssembly)) &&
|
|
92
|
+
wantTracks.every(id => openTracks.includes(id)));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// No census: a deployed build older than the marker's attributes, so
|
|
99
|
+
// walk the session model it publishes instead.
|
|
50
100
|
const session = globalThis.JBrowseSession;
|
|
51
101
|
const views = session?.views;
|
|
52
102
|
if (!views?.length) {
|
|
@@ -71,7 +121,7 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
|
|
|
71
121
|
}
|
|
72
122
|
const tracksOf = (v) => [
|
|
73
123
|
...(v.tracks ?? []),
|
|
74
|
-
...(v.levels ?? []).flatMap(l => l.tracks ?? []),
|
|
124
|
+
...(v.trackContainers ?? v.levels ?? []).flatMap(l => l.tracks ?? []),
|
|
75
125
|
...(v.views ?? []).flatMap(tracksOf),
|
|
76
126
|
];
|
|
77
127
|
const open = new Set(views.flatMap(v => tracksOf(v).map(t => t.configuration?.trackId)));
|
|
@@ -80,7 +130,10 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
|
|
|
80
130
|
// #endregion session-gate
|
|
81
131
|
}
|
|
82
132
|
catch {
|
|
83
|
-
|
|
133
|
+
// Best-effort: the wait may have failed because the page crashed or
|
|
134
|
+
// navigated, and an unguarded evaluate here would replace the diagnostic
|
|
135
|
+
// below with its own opaque error.
|
|
136
|
+
const summary = await readSessionSummary(page).catch(() => undefined);
|
|
84
137
|
const found = summary
|
|
85
138
|
? `${summary.views} view(s), assemblies [${summary.assemblies.join(', ')}], tracks [${summary.trackIds.join(', ')}]`
|
|
86
139
|
: 'no session on the page at all (is this a jbrowse-web instance?)';
|
|
@@ -150,10 +203,8 @@ export function readInstrumentation(page) {
|
|
|
150
203
|
* result means nothing at all on a build with no paint contract — check
|
|
151
204
|
* `paintContract` before reading it as good news.
|
|
152
205
|
*/
|
|
153
|
-
export function pendingDisplays(page) {
|
|
154
|
-
return page.
|
|
155
|
-
...document.querySelectorAll('[data-display-drawn="false"]'),
|
|
156
|
-
].map(el => el.dataset.testid ?? (el.id || 'unnamed display')));
|
|
206
|
+
export async function pendingDisplays(page) {
|
|
207
|
+
return (await pendingDisplayStates(page)).map(d => d.name);
|
|
157
208
|
}
|
|
158
209
|
/**
|
|
159
210
|
* The same census, with each display's own account of itself.
|
package/esm/waits.d.ts
CHANGED
|
@@ -17,6 +17,14 @@ export declare const LOADING_OVERLAY = "[data-testid=\"loading-overlay\"]";
|
|
|
17
17
|
* capture taken in it is a picture of an empty browser.
|
|
18
18
|
*/
|
|
19
19
|
export declare const APP_READY = "[data-app-phase=\"ready\"]";
|
|
20
|
+
/**
|
|
21
|
+
* Wait until the loading overlay is gone, and with `waitForDownloads` until
|
|
22
|
+
* nothing else reports itself busy either — a remote fetch can outlive the
|
|
23
|
+
* overlay, so a capture taken on the overlay alone catches a half-loaded track.
|
|
24
|
+
*
|
|
25
|
+
* NOT best-effort: an overlay that never clears means a fetch that never
|
|
26
|
+
* finished, and there is no content behind it to fall through to.
|
|
27
|
+
*/
|
|
20
28
|
export declare function waitForLoadingComplete(page: Page, { timeout, waitForDownloads, }?: {
|
|
21
29
|
timeout?: number;
|
|
22
30
|
waitForDownloads?: boolean;
|
|
@@ -106,15 +114,16 @@ export declare const BUSY_SELECTOR: string;
|
|
|
106
114
|
/**
|
|
107
115
|
* Is the app doing anything right now?
|
|
108
116
|
*
|
|
109
|
-
* Serialized into the page, so it declares everything it uses
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
117
|
+
* Serialized into the page, so it declares everything it uses and takes
|
|
118
|
+
* `BUSY_SELECTOR` as an argument rather than importing it. Two sources: that
|
|
119
|
+
* selector, and the live session model's own per-display status, which is the
|
|
120
|
+
* only PER-DISPLAY signal a build with no readiness attributes has left. Both
|
|
121
|
+
* are contracts rather than renderings — a model field and a set of data
|
|
113
122
|
* attributes — so neither moves when the UI is restyled.
|
|
114
123
|
*
|
|
115
124
|
* Exported so a test can call the real function rather than a copy of it.
|
|
116
125
|
*/
|
|
117
|
-
export declare function isPageBusyInPage(): boolean;
|
|
126
|
+
export declare function isPageBusyInPage(busySelector: string): boolean;
|
|
118
127
|
/**
|
|
119
128
|
* Wait until the app has been idle for an unbroken stretch.
|
|
120
129
|
*
|
|
@@ -163,12 +172,16 @@ export declare function waitForQuietPeriod(page: Page, { quietMs, timeout, pollM
|
|
|
163
172
|
busyWindowMs?: number;
|
|
164
173
|
}): Promise<boolean>;
|
|
165
174
|
/**
|
|
166
|
-
* Wait for the app
|
|
175
|
+
* Wait for the FIRST frame the app says it has finished.
|
|
176
|
+
*
|
|
177
|
+
* `[data-app-phase="ready"]` is rendered by the session itself, so it cannot be
|
|
178
|
+
* satisfied before the app exists — but one frame of it is not the answer on its
|
|
179
|
+
* own: a display reads `ready` in the gap between one fetch finishing and the
|
|
180
|
+
* debounced next one starting. `waitForAppSettled` requires it to HOLD, and is
|
|
181
|
+
* what both the chain here and `jb.waitReady` use.
|
|
167
182
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* page never publishes it, which is how a caller tells "not ready yet" from "a
|
|
171
|
-
* build too old to have the marker" and falls back.
|
|
183
|
+
* Returns false if the page never publishes it, which is how a caller tells "not
|
|
184
|
+
* ready yet" from "a build too old to have the marker" and falls back.
|
|
172
185
|
*/
|
|
173
186
|
export declare function waitForAppReady(page: Page, { timeout }?: {
|
|
174
187
|
timeout?: number;
|
|
@@ -178,12 +191,12 @@ export declare function hasAppReadyMarker(page: Page): Promise<boolean>;
|
|
|
178
191
|
/**
|
|
179
192
|
* Wait out work an INTERACTION started: `ready`, and still ready a beat later.
|
|
180
193
|
*
|
|
181
|
-
* The gate
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* read
|
|
194
|
+
* The gate wherever `waitForAppReady` alone is not one, which is everywhere the
|
|
195
|
+
* app is working. After an interaction the app is already `ready` — it was
|
|
196
|
+
* finished a moment ago — and stays that way until the click's work registers,
|
|
197
|
+
* so a single read returns instantly, on the pre-click frame; during a load it
|
|
198
|
+
* reads `ready` in the gap between one fetch finishing and the debounced next
|
|
199
|
+
* one starting, and a single read takes that gap for the end.
|
|
187
200
|
*
|
|
188
201
|
* What it replaces is a fixed sleep, which is wrong in both directions and only
|
|
189
202
|
* ever caught in one: too short captures the work in progress, and the figure it
|
package/esm/waits.js
CHANGED
|
@@ -4,12 +4,6 @@ import { describePendingDisplays, pendingDisplayStatesInPage, } from './sessionG
|
|
|
4
4
|
export const delay = (ms) => new Promise(resolve => {
|
|
5
5
|
setTimeout(resolve, ms);
|
|
6
6
|
});
|
|
7
|
-
// Wait until the LoadingOverlay test-id is gone. NOTE: the overlay keeps the
|
|
8
|
-
// literal text "Loading" in the DOM (hidden via opacity), so a text-based wait
|
|
9
|
-
// burns its full timeout — only the `loading-overlay` test-id count is a
|
|
10
|
-
// reliable signal. With `waitForDownloads`, also wait out adapter "Downloading…"
|
|
11
|
-
// status text, which can linger after the overlay clears (e.g. a remote BAM
|
|
12
|
-
// still fetching) so a capture doesn't catch a half-loaded track.
|
|
13
7
|
// Every best-effort wait below runs through this. They swallow their own timeout
|
|
14
8
|
// on purpose — a slow-but-finishing page should not be failed for being slow,
|
|
15
9
|
// and a display in a terminal state publishes no attribute to wait on at all —
|
|
@@ -52,17 +46,16 @@ export const APP_READY = '[data-app-phase="ready"]';
|
|
|
52
46
|
* in the gap reports the pre-click frame as finished.
|
|
53
47
|
*/
|
|
54
48
|
const APP_SETTLED_HOLD_MS = 1000;
|
|
49
|
+
/**
|
|
50
|
+
* Wait until the loading overlay is gone, and with `waitForDownloads` until
|
|
51
|
+
* nothing else reports itself busy either — a remote fetch can outlive the
|
|
52
|
+
* overlay, so a capture taken on the overlay alone catches a half-loaded track.
|
|
53
|
+
*
|
|
54
|
+
* NOT best-effort: an overlay that never clears means a fetch that never
|
|
55
|
+
* finished, and there is no content behind it to fall through to.
|
|
56
|
+
*/
|
|
55
57
|
export async function waitForLoadingComplete(page, { timeout = 30000, waitForDownloads = false, } = {}) {
|
|
56
|
-
|
|
57
|
-
// finished, and there is no content behind it to fall through to.
|
|
58
|
-
await page.waitForFunction((selector) => document.querySelectorAll(selector).length === 0, { timeout }, LOADING_OVERLAY);
|
|
59
|
-
// The download half asks the app what it is doing, through the attributes it
|
|
60
|
-
// publishes and each display's own status on the session model. It used to be
|
|
61
|
-
// `document.body.innerText.includes('Downloading')`, a string search over the
|
|
62
|
-
// whole rendered page: it matched a track NAMED "Downloading…", it matched
|
|
63
|
-
// documentation text, and it stopped matching the moment a message was
|
|
64
|
-
// reworded or translated.
|
|
65
|
-
//
|
|
58
|
+
await page.waitForFunction((selector) => document.querySelectorAll(selector).length === 0, { timeout, polling: 'mutation' }, LOADING_OVERLAY);
|
|
66
59
|
// `quietMs: 0` because this is the "nothing is in flight right now" question;
|
|
67
60
|
// the one that needs the idle to hold is waitForQuietPeriod's own caller.
|
|
68
61
|
return waitForDownloads
|
|
@@ -185,22 +178,27 @@ export const displayPainted = (testid) => `[data-testid="${testid}"][data-displa
|
|
|
185
178
|
export const displaySettled = (testid) => `[data-testid="${testid}"][data-display-phase="ready"]`;
|
|
186
179
|
/** One display, by its config's `displayId` rather than by type. */
|
|
187
180
|
export const displayById = (displayId) => `[data-display-id="${displayId}"]`;
|
|
188
|
-
// Wait until no display
|
|
189
|
-
//
|
|
190
|
-
//
|
|
181
|
+
// Wait until no display is still pending its first paint AND could still reach
|
|
182
|
+
// it: a pending display publishing `loading`, or publishing no phase at all (a
|
|
183
|
+
// build older than the attribute), is still coming; a terminal phase is not. The
|
|
184
|
+
// two comparative canvases hold `drawn=false` open through `error` deliberately,
|
|
185
|
+
// so waiting on the attribute alone burns the whole timeout over an answer the
|
|
186
|
+
// census already has — waitForJBrowseReady takes that census straight after and
|
|
187
|
+
// reports whatever is left as unsettled.
|
|
191
188
|
//
|
|
192
189
|
// Keying on the *absence* of pending wrappers rather than counting done-vs-total
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// for the last one; the previous "any element ends in -done" fallback
|
|
197
|
-
// soon as the *first* of several tracks painted.
|
|
190
|
+
// is what lets a page with no canvas displays — an import form, a menu or widget
|
|
191
|
+
// figure — resolve immediately instead of paying the full timeout as a hidden
|
|
192
|
+
// fixed sleep, and what makes a page whose displays finish at different times
|
|
193
|
+
// wait for the last one; the previous "any element ends in -done" fallback
|
|
194
|
+
// returned as soon as the *first* of several tracks painted.
|
|
198
195
|
//
|
|
199
196
|
// Absence is only meaningful once the views have mounted (a track's display
|
|
200
197
|
// wrapper mounts with its TrackRenderingContainer), so call this after the
|
|
201
198
|
// readySelector / loading-overlay gates, not straight off a navigation.
|
|
202
199
|
export function waitForDisplaysDone(page, timeoutMs) {
|
|
203
|
-
return settled(page.waitForFunction((selector) => document.
|
|
200
|
+
return settled(page.waitForFunction((selector) => [...document.querySelectorAll(selector)].every(el => el.dataset.displayPhase !== undefined &&
|
|
201
|
+
el.dataset.displayPhase !== 'loading'), { timeout: timeoutMs, polling: 'mutation' }, PENDING_DISPLAYS));
|
|
204
202
|
}
|
|
205
203
|
// Wait until no display is in its `loading` phase.
|
|
206
204
|
//
|
|
@@ -221,7 +219,7 @@ export function waitForDisplaysDone(page, timeoutMs) {
|
|
|
221
219
|
// fail loudly through that settled check, with the frame to look at, rather than
|
|
222
220
|
// as an opaque timeout here.
|
|
223
221
|
export function waitForDisplayPhases(page, timeoutMs) {
|
|
224
|
-
return settled(page.waitForFunction(() => document.querySelector('[data-display-phase="loading"]') === null, { timeout: timeoutMs }));
|
|
222
|
+
return settled(page.waitForFunction(() => document.querySelector('[data-display-phase="loading"]') === null, { timeout: timeoutMs, polling: 'mutation' }));
|
|
225
223
|
}
|
|
226
224
|
// The view-level counterpart: ViewContainer publishes `data-view-phase` from the
|
|
227
225
|
// view model's own phase, and `loading` means the view is still waiting on its
|
|
@@ -242,7 +240,7 @@ export function waitForDisplayPhases(page, timeoutMs) {
|
|
|
242
240
|
// should surface it.
|
|
243
241
|
export function waitForViewPhases(page, timeoutMs) {
|
|
244
242
|
return page.waitForFunction(() => document.querySelector('[data-view-phase="loading"]') === null &&
|
|
245
|
-
document.querySelector('[data-view-component-pending]') === null, { timeout: timeoutMs, polling:
|
|
243
|
+
document.querySelector('[data-view-component-pending]') === null, { timeout: timeoutMs, polling: 'mutation' });
|
|
246
244
|
}
|
|
247
245
|
/**
|
|
248
246
|
* Everything the app publishes to say it is working, as one selector.
|
|
@@ -267,21 +265,16 @@ export const BUSY_SELECTOR = [
|
|
|
267
265
|
/**
|
|
268
266
|
* Is the app doing anything right now?
|
|
269
267
|
*
|
|
270
|
-
* Serialized into the page, so it declares everything it uses
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
268
|
+
* Serialized into the page, so it declares everything it uses and takes
|
|
269
|
+
* `BUSY_SELECTOR` as an argument rather than importing it. Two sources: that
|
|
270
|
+
* selector, and the live session model's own per-display status, which is the
|
|
271
|
+
* only PER-DISPLAY signal a build with no readiness attributes has left. Both
|
|
272
|
+
* are contracts rather than renderings — a model field and a set of data
|
|
274
273
|
* attributes — so neither moves when the UI is restyled.
|
|
275
274
|
*
|
|
276
275
|
* Exported so a test can call the real function rather than a copy of it.
|
|
277
276
|
*/
|
|
278
|
-
export function isPageBusyInPage() {
|
|
279
|
-
const busySelector = [
|
|
280
|
-
'[data-testid="loading-overlay"]',
|
|
281
|
-
'[data-busy="true"]',
|
|
282
|
-
'[data-display-phase="loading"]',
|
|
283
|
-
'[data-view-phase="loading"]',
|
|
284
|
-
].join(', ');
|
|
277
|
+
export function isPageBusyInPage(busySelector) {
|
|
285
278
|
if (document.querySelector(busySelector)) {
|
|
286
279
|
return true;
|
|
287
280
|
}
|
|
@@ -290,7 +283,7 @@ export function isPageBusyInPage() {
|
|
|
290
283
|
const tracksOf = (c) => (c.tracks ?? []).flatMap(t => t.displays ?? []);
|
|
291
284
|
const displaysOf = (v) => [
|
|
292
285
|
...tracksOf(v),
|
|
293
|
-
...(v.trackContainers ?? []).flatMap(tracksOf),
|
|
286
|
+
...(v.trackContainers ?? v.levels ?? []).flatMap(tracksOf),
|
|
294
287
|
...(v.views ?? []).flatMap(displaysOf),
|
|
295
288
|
];
|
|
296
289
|
return (session?.views ?? [])
|
|
@@ -330,7 +323,9 @@ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000
|
|
|
330
323
|
while (Date.now() < deadline) {
|
|
331
324
|
// A page that navigates or closes under us fails the evaluate; treat that
|
|
332
325
|
// as busy and let the deadline decide, rather than reporting quiet.
|
|
333
|
-
const busy = await page
|
|
326
|
+
const busy = await page
|
|
327
|
+
.evaluate(isPageBusyInPage, BUSY_SELECTOR)
|
|
328
|
+
.catch(() => true);
|
|
334
329
|
const now = Date.now();
|
|
335
330
|
if (busy) {
|
|
336
331
|
seenBusy = true;
|
|
@@ -338,9 +333,11 @@ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000
|
|
|
338
333
|
}
|
|
339
334
|
else {
|
|
340
335
|
if (!seenBusy && now - start >= busyWindowMs) {
|
|
341
|
-
//
|
|
336
|
+
// Never went busy within the window: nothing to wait out, and the idle
|
|
337
|
+
// that already ran the length of the window counts towards the hold —
|
|
338
|
+
// every sample in it was idle, since a failed evaluate reads as busy.
|
|
342
339
|
seenBusy = true;
|
|
343
|
-
quietSince =
|
|
340
|
+
quietSince = start;
|
|
344
341
|
}
|
|
345
342
|
if (seenBusy) {
|
|
346
343
|
quietSince ??= now;
|
|
@@ -354,12 +351,16 @@ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000
|
|
|
354
351
|
return false;
|
|
355
352
|
}
|
|
356
353
|
/**
|
|
357
|
-
* Wait for the app
|
|
354
|
+
* Wait for the FIRST frame the app says it has finished.
|
|
355
|
+
*
|
|
356
|
+
* `[data-app-phase="ready"]` is rendered by the session itself, so it cannot be
|
|
357
|
+
* satisfied before the app exists — but one frame of it is not the answer on its
|
|
358
|
+
* own: a display reads `ready` in the gap between one fetch finishing and the
|
|
359
|
+
* debounced next one starting. `waitForAppSettled` requires it to HOLD, and is
|
|
360
|
+
* what both the chain here and `jb.waitReady` use.
|
|
358
361
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* page never publishes it, which is how a caller tells "not ready yet" from "a
|
|
362
|
-
* build too old to have the marker" and falls back.
|
|
362
|
+
* Returns false if the page never publishes it, which is how a caller tells "not
|
|
363
|
+
* ready yet" from "a build too old to have the marker" and falls back.
|
|
363
364
|
*/
|
|
364
365
|
export function waitForAppReady(page, { timeout = 30000 } = {}) {
|
|
365
366
|
return settled(page.waitForSelector(APP_READY, { timeout, visible: false }));
|
|
@@ -371,12 +372,12 @@ export function hasAppReadyMarker(page) {
|
|
|
371
372
|
/**
|
|
372
373
|
* Wait out work an INTERACTION started: `ready`, and still ready a beat later.
|
|
373
374
|
*
|
|
374
|
-
* The gate
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* read
|
|
375
|
+
* The gate wherever `waitForAppReady` alone is not one, which is everywhere the
|
|
376
|
+
* app is working. After an interaction the app is already `ready` — it was
|
|
377
|
+
* finished a moment ago — and stays that way until the click's work registers,
|
|
378
|
+
* so a single read returns instantly, on the pre-click frame; during a load it
|
|
379
|
+
* reads `ready` in the gap between one fetch finishing and the debounced next
|
|
380
|
+
* one starting, and a single read takes that gap for the end.
|
|
380
381
|
*
|
|
381
382
|
* What it replaces is a fixed sleep, which is wrong in both directions and only
|
|
382
383
|
* ever caught in one: too short captures the work in progress, and the figure it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbrowse/capture",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.3",
|
|
4
4
|
"description": "Drive a JBrowse 2 instance with Puppeteer and screenshot it once it has actually finished rendering",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jbrowse",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
],
|
|
32
32
|
"sideEffects": false,
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"puppeteer": "^25.
|
|
34
|
+
"puppeteer": "^25.10.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@jbrowse/core": "^5.0.0-beta.
|
|
37
|
+
"@jbrowse/core": "^5.0.0-beta.3"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|