@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/waits.js
CHANGED
|
@@ -1,428 +1,101 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// and a display in a terminal state publishes no attribute to wait on at all —
|
|
16
|
-
// but swallowing it in silence is what makes a blank capture unattributable:
|
|
17
|
-
// "everything settled" and "we gave up" become the same `void`. So the outcome
|
|
18
|
-
// comes back as a boolean instead. Callers that only `await` are unaffected;
|
|
19
|
-
// callers that care (see waitForJBrowseReady) can report or fail on it.
|
|
20
|
-
async function settled(work) {
|
|
1
|
+
import { DEFAULT_TIMEOUT, holdTrue } from './poll.js';
|
|
2
|
+
import { describeDisplays, displayCensusInPage } from './sessionGate.js';
|
|
3
|
+
/** The app's own verdict: no view is resolving an assembly, no display is fetching. */
|
|
4
|
+
export const APP_READY = '[data-app-phase="ready"]';
|
|
5
|
+
/** Displays that have not yet drawn anything. */
|
|
6
|
+
export const PENDING_DISPLAYS = '[data-display-drawn="false"]';
|
|
7
|
+
// longer than the ~600ms FetchVisibleRegions debounce, so the gap between one
|
|
8
|
+
// fetch and the next cannot pass for settled
|
|
9
|
+
const APP_SETTLED_HOLD_MS = 1000;
|
|
10
|
+
/** A display of this type that has drawn something, possibly mid-fetch. */
|
|
11
|
+
export const displayPainted = (testid) => `[data-testid="${testid}"][data-display-drawn="true"]`;
|
|
12
|
+
/** A display of this type whose fetch has finished. */
|
|
13
|
+
export const displaySettled = (testid) => `[data-testid="${testid}"][data-display-phase="ready"]`;
|
|
14
|
+
async function describeDisplaysNow(page) {
|
|
21
15
|
try {
|
|
22
|
-
await
|
|
23
|
-
|
|
16
|
+
const { pending, tooLarge } = await page.evaluate(displayCensusInPage);
|
|
17
|
+
const found = [
|
|
18
|
+
pending.length
|
|
19
|
+
? `${pending.length} display(s) were not showing their data: ${describeDisplays(pending)}`
|
|
20
|
+
: undefined,
|
|
21
|
+
tooLarge.length
|
|
22
|
+
? `${tooLarge.length} display(s) show "too much data" in place of their features: ${describeDisplays(tooLarge)}`
|
|
23
|
+
: undefined,
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
return found.length
|
|
26
|
+
? found.join('; ')
|
|
27
|
+
: 'no display reported itself unpainted, so the selector was not on a display';
|
|
24
28
|
}
|
|
25
29
|
catch {
|
|
26
|
-
return
|
|
30
|
+
return 'the page could not be queried afterwards (context gone)';
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
|
-
export const LOADING_OVERLAY = '[data-testid="loading-overlay"]';
|
|
30
|
-
/**
|
|
31
|
-
* The whole readiness contract, in one selector.
|
|
32
|
-
*
|
|
33
|
-
* `AppReadyMarker` renders it from the session: it reads `ready` when no view
|
|
34
|
-
* is resolving an assembly and no display is fetching, and `loading` whenever
|
|
35
|
-
* one is. Waiting for it is the entire job — everything else in this module is
|
|
36
|
-
* either a narrower question (has THIS display painted) or a fallback for a
|
|
37
|
-
* deployment too old to publish it.
|
|
38
|
-
*
|
|
39
|
-
* It is POSITIVE, which is the property that matters. Every other signal here
|
|
40
|
-
* is an absence, and an absence is equally true of an app that has not started:
|
|
41
|
-
* measured on a two-track session, the gap between "the session holds the
|
|
42
|
-
* tracks" and "the first loading indicator appears" is about a second, and a
|
|
43
|
-
* capture taken in it is a picture of an empty browser.
|
|
44
|
-
*/
|
|
45
|
-
export const APP_READY = '[data-app-phase="ready"]';
|
|
46
|
-
/**
|
|
47
|
-
* How long `ready` has to HOLD before an interaction's work counts as finished.
|
|
48
|
-
*
|
|
49
|
-
* Above the ~600ms `FetchVisibleRegions` debounce, which is the whole reason
|
|
50
|
-
* this is a hold rather than a single read: a click that dirties the viewport
|
|
51
|
-
* leaves the app reading `ready` until that autorun fires, so one sample taken
|
|
52
|
-
* in the gap reports the pre-click frame as finished.
|
|
53
|
-
*/
|
|
54
|
-
const APP_SETTLED_HOLD_MS = 1000;
|
|
55
|
-
export async function waitForLoadingComplete(page, { timeout = 30000, waitForDownloads = false, } = {}) {
|
|
56
|
-
// NOT best-effort: an overlay that never clears means a fetch that never
|
|
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
|
-
//
|
|
66
|
-
// `quietMs: 0` because this is the "nothing is in flight right now" question;
|
|
67
|
-
// the one that needs the idle to hold is waitForQuietPeriod's own caller.
|
|
68
|
-
return waitForDownloads
|
|
69
|
-
? waitForQuietPeriod(page, { quietMs: 0, timeout })
|
|
70
|
-
: true;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Wait until nothing on the page is in flight.
|
|
74
|
-
*
|
|
75
|
-
* Every signal it reads is one the app publishes deliberately — the loading
|
|
76
|
-
* overlay, `data-busy`, the display and view phases, and each display's own
|
|
77
|
-
* status message on the session model. See `isPageBusyInPage`.
|
|
78
|
-
*
|
|
79
|
-
* It used to scan rendered text for /^(loading|rendering|computing)/ and resolve
|
|
80
|
-
* `getComputedStyle` on each match to decide whether it was on screen. That is a
|
|
81
|
-
* heuristic over a rendering: it broke on a reworded or translated message, it
|
|
82
|
-
* needed the visibility check only because the loading overlay keeps the word
|
|
83
|
-
* "Loading" in the DOM at `opacity: 0`, and an unset opacity parses to zero
|
|
84
|
-
* outside a full layout engine, which reads every ordinary element as hidden. A
|
|
85
|
-
* component that wants to be waited for now says so with an attribute.
|
|
86
|
-
*
|
|
87
|
-
* Best-effort: a view genuinely stuck loading would otherwise burn the whole
|
|
88
|
-
* timeout, so this reports false rather than throwing and the caller decides.
|
|
89
|
-
*/
|
|
90
|
-
export function waitForQuiescent(page, { timeout = 30000 } = {}) {
|
|
91
|
-
return waitForQuietPeriod(page, { quietMs: 0, timeout });
|
|
92
|
-
}
|
|
93
|
-
// "Every display has painted" = no display is still reporting
|
|
94
|
-
// `data-display-drawn="false"`. DisplayChrome publishes that directly, so this
|
|
95
|
-
// no longer has to infer paint state from the *shape* of a test id: it used to
|
|
96
|
-
// be a three-way union — `display-<displayId>` not ending in `-done`, plus
|
|
97
|
-
// anything ending in `-display`, plus synteny — because the id both identified
|
|
98
|
-
// the display and encoded its paint state by mutating, and the bases came in
|
|
99
|
-
// three shapes depending on whether a second wrapper element was involved. One
|
|
100
|
-
// element per display and one stable attribute retire all of that — including
|
|
101
|
-
// for the two non-LGV views, which have no chrome but publish the same attribute
|
|
102
|
-
// through `RenderCanvas`. That closed a real hole: the old list named
|
|
103
|
-
// `synteny_canvas` explicitly and simply forgot dotplot, so an unpainted dotplot
|
|
104
|
-
// counted as finished here and a capture could land on it blank.
|
|
105
|
-
//
|
|
106
|
-
// Exported so a caller can re-check the post-condition after the wait and say
|
|
107
|
-
// whether it actually settled or merely timed out. `waitForDisplaysDone` (like
|
|
108
|
-
// its neighbours) swallows its own timeout on purpose, which leaves "every
|
|
109
|
-
// display painted" and "we gave up waiting" indistinguishable at the call site —
|
|
110
|
-
// and that ambiguity is what makes a blank capture unattributable.
|
|
111
|
-
export const PENDING_DISPLAYS = '[data-display-drawn="false"]';
|
|
112
33
|
/**
|
|
113
|
-
* `page.waitForSelector`,
|
|
114
|
-
*
|
|
115
|
-
* The waits below are best-effort and hand their outcome back as a boolean, so
|
|
116
|
-
* a caller can say what was still unsettled. A *selector* wait is the opposite
|
|
117
|
-
* shape — it throws, and it runs FIRST, before any of them: a capture whose
|
|
118
|
-
* display never paints dies here on puppeteer's `TimeoutError`, which names the
|
|
119
|
-
* selector and nothing else, and the census that would have named the display
|
|
120
|
-
* runs later or not at all.
|
|
121
|
-
*
|
|
122
|
-
* So the census runs on the way out. It is re-read from the DOM here rather
|
|
123
|
-
* than taken off a handle the wait held: a handle to an element that has since
|
|
124
|
-
* re-rendered throws `Node is detached from document`, which is how the first
|
|
125
|
-
* attempt at this turned four diagnosable timeouts into nine opaque puppeteer
|
|
126
|
-
* errors (reverted in 28c6ee6d90).
|
|
127
|
-
*
|
|
128
|
-
* What it reports is the phase, and that is the part a longer timeout cannot
|
|
129
|
-
* substitute for: `loading` is a slow fetch, `error` is a display that has
|
|
130
|
-
* finished badly and is showing a banner, and `ready` is a display claiming it
|
|
131
|
-
* finished without drawing — a bug in the display rather than in the wait.
|
|
132
|
-
*
|
|
133
|
-
* An EMPTY census is the other answer, and `tooLarge` / `renderError` are why it
|
|
134
|
-
* is not good news. Those two replace the display's subtree outright, so they
|
|
135
|
-
* publish neither attribute and appear here as nothing at all: a wait on such a
|
|
136
|
-
* display can never resolve and can never be reported. Nothing pending means
|
|
137
|
-
* either that, or a selector that was never a display's.
|
|
34
|
+
* `page.waitForSelector`, whose timeout names each display that had not
|
|
35
|
+
* painted and the phase it was in.
|
|
138
36
|
*/
|
|
139
|
-
export async function waitForSelectorAttributed(page, selector,
|
|
37
|
+
export async function waitForSelectorAttributed(page, selector, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
140
38
|
try {
|
|
141
|
-
const handle = await page.waitForSelector(selector, { timeout
|
|
39
|
+
const handle = await page.waitForSelector(selector, { timeout });
|
|
142
40
|
if (!handle) {
|
|
143
41
|
throw new Error(`element not found: ${selector}`);
|
|
144
42
|
}
|
|
145
43
|
return handle;
|
|
146
44
|
}
|
|
147
45
|
catch (cause) {
|
|
148
|
-
throw new Error(`${selector} did not appear within ${
|
|
46
|
+
throw new Error(`${selector} did not appear within ${timeout}ms — ${await describeDisplaysNow(page)}`, { cause });
|
|
149
47
|
}
|
|
150
48
|
}
|
|
49
|
+
// What each term of the settled hold waits out, as a timeout names it. The
|
|
50
|
+
// marker reads `loading` over a display that is fetching or has not painted,
|
|
51
|
+
// but only over the displays its view walk finds, so a plugin view that does
|
|
52
|
+
// not declare its `ownTracks` is held by the display term. `data-busy` is every
|
|
53
|
+
// `LoadingEllipses`: a view body still loading its code, and the panels,
|
|
54
|
+
// widgets and dialogs no view walk reaches.
|
|
55
|
+
const SETTLE_BLOCKERS = [
|
|
56
|
+
['[data-app-phase="loading"]', 'an app still loading'],
|
|
57
|
+
['[data-display-phase="loading"]', 'a display still loading'],
|
|
58
|
+
['[data-busy="true"]', 'a loading indicator still up'],
|
|
59
|
+
['[data-display-animating="true"]', 'a display still animating'],
|
|
60
|
+
];
|
|
61
|
+
const NO_MARKER = 'no [data-app-phase] on the page';
|
|
151
62
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
63
|
+
* `waitForAppSettled`, answering with what was still blocking at the timeout,
|
|
64
|
+
* or undefined once the hold passed.
|
|
154
65
|
*/
|
|
155
|
-
export async function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
66
|
+
export async function appSettledBlocker(page, { timeout = DEFAULT_TIMEOUT, holdMs = APP_SETTLED_HOLD_MS, pollMs = 250, } = {}) {
|
|
67
|
+
const blocker = () => page
|
|
68
|
+
.evaluate((ready, blockers, noMarker) => document.querySelector('[data-app-phase]') === null
|
|
69
|
+
? noMarker
|
|
70
|
+
: (blockers.find(([selector]) => document.querySelector(selector))?.[1] ??
|
|
71
|
+
(document.querySelector(ready) === null
|
|
72
|
+
? 'the marker not reading ready'
|
|
73
|
+
: undefined)), APP_READY, SETTLE_BLOCKERS, NO_MARKER)
|
|
74
|
+
.catch(() => 'the page could not be queried');
|
|
75
|
+
const held = await holdTrue(async () => (await blocker()) === undefined, {
|
|
76
|
+
holdMs,
|
|
77
|
+
timeout,
|
|
78
|
+
pollMs,
|
|
79
|
+
});
|
|
80
|
+
if (held) {
|
|
81
|
+
return undefined;
|
|
161
82
|
}
|
|
162
|
-
|
|
163
|
-
|
|
83
|
+
const last = await blocker();
|
|
84
|
+
if (last === NO_MARKER) {
|
|
85
|
+
throw new Error(`this page published no [data-app-phase] within ${timeout}ms, so there ` +
|
|
86
|
+
'is nothing positive to wait for — every other readiness attribute is ' +
|
|
87
|
+
'an absence an app that has not started also satisfies. A JBrowse ' +
|
|
88
|
+
'older than v5 publishes none.');
|
|
164
89
|
}
|
|
90
|
+
return last ?? 'a hold the timeout cut short';
|
|
165
91
|
}
|
|
166
|
-
// The composite selectors, so "this display type, painted" is written once.
|
|
167
|
-
//
|
|
168
|
-
// `data-testid` names the display TYPE and is stable; the readiness attributes
|
|
169
|
-
// are separate and orthogonal. That split is deliberate (ADR-065) and it is what
|
|
170
|
-
// makes `PENDING_DISPLAYS` above a single selector — but it does mean the
|
|
171
|
-
// everyday question, "has the pileup finished", is a conjunction rather than one
|
|
172
|
-
// attribute. Writing that conjunction out at each call site is what previously
|
|
173
|
-
// produced two suffix conventions and a `[data-testid$="-done"],
|
|
174
|
-
// [data-testid$="_done"]` union, so it is a function here instead.
|
|
175
|
-
//
|
|
176
|
-
// No `CSS.escape`: these are our own testids, fixed identifiers chosen in the
|
|
177
|
-
// display components, and the escape function does not exist in node (where
|
|
178
|
-
// these strings are built) nor in jsdom.
|
|
179
|
-
export const displayPainted = (testid) => `[data-testid="${testid}"][data-display-drawn="true"]`;
|
|
180
92
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
93
|
+
* Wait until every app on the page has read ready, with nothing loading or
|
|
94
|
+
* animating, for an unbroken `holdMs`. False on timeout. Right after a
|
|
95
|
+
* navigation it first waits for the marker to mount; a page that never
|
|
96
|
+
* publishes `[data-app-phase]` throws, since nothing positive exists to wait
|
|
97
|
+
* for.
|
|
184
98
|
*/
|
|
185
|
-
export
|
|
186
|
-
|
|
187
|
-
export const displayById = (displayId) => `[data-display-id="${displayId}"]`;
|
|
188
|
-
// Wait until no display wrapper is still pending its first paint, or until the
|
|
189
|
-
// timeout elapses (proceed anyway — a display stuck in its too-large/error state
|
|
190
|
-
// renders no wrapper at all and never reports done).
|
|
191
|
-
//
|
|
192
|
-
// Keying on the *absence* of pending wrappers rather than counting done-vs-total
|
|
193
|
-
// matters twice over. A page with no canvas displays — an import form, a menu or
|
|
194
|
-
// widget figure — resolves immediately instead of burning the full timeout as a
|
|
195
|
-
// hidden fixed sleep. And a page whose displays finish at different times waits
|
|
196
|
-
// for the last one; the previous "any element ends in -done" fallback returned as
|
|
197
|
-
// soon as the *first* of several tracks painted.
|
|
198
|
-
//
|
|
199
|
-
// Absence is only meaningful once the views have mounted (a track's display
|
|
200
|
-
// wrapper mounts with its TrackRenderingContainer), so call this after the
|
|
201
|
-
// readySelector / loading-overlay gates, not straight off a navigation.
|
|
202
|
-
export function waitForDisplaysDone(page, timeoutMs) {
|
|
203
|
-
return settled(page.waitForFunction((selector) => document.querySelector(selector) === null, { timeout: timeoutMs }, PENDING_DISPLAYS));
|
|
204
|
-
}
|
|
205
|
-
// Wait until no display is in its `loading` phase.
|
|
206
|
-
//
|
|
207
|
-
// This is the signal the other waits here only approximate. `waitForDisplaysDone`
|
|
208
|
-
// keys on canvasDrawn (FIRST paint — flips on an empty canvas with the fetch
|
|
209
|
-
// still running), `waitForLoadingComplete` on an overlay that a debounced fetch
|
|
210
|
-
// may not have raised yet, and `waitForQuiescent` on status *text*. DisplayChrome
|
|
211
|
-
// publishes `data-display-phase` from the model's own mutually-exclusive
|
|
212
|
-
// DisplayPhase, whose `loading` term covers the entire fetch, so "nothing is
|
|
213
|
-
// loading" is a direct read rather than an inference.
|
|
214
|
-
//
|
|
215
|
-
// Terminal phases (`tooLarge`, `renderError`) replace the display subtree and
|
|
216
|
-
// publish no attribute, so they resolve immediately here — correct, since they
|
|
217
|
-
// are finished, not pending. They're caught as *content* by the caller's own
|
|
218
|
-
// settled check, not by this wait.
|
|
219
|
-
//
|
|
220
|
-
// Best-effort like its neighbours: a display that never leaves `loading` should
|
|
221
|
-
// fail loudly through that settled check, with the frame to look at, rather than
|
|
222
|
-
// as an opaque timeout here.
|
|
223
|
-
export function waitForDisplayPhases(page, timeoutMs) {
|
|
224
|
-
return settled(page.waitForFunction(() => document.querySelector('[data-display-phase="loading"]') === null, { timeout: timeoutMs }));
|
|
225
|
-
}
|
|
226
|
-
// The view-level counterpart: ViewContainer publishes `data-view-phase` from the
|
|
227
|
-
// view model's own phase, and `loading` means the view is still waiting on its
|
|
228
|
-
// assembly (or on init's navigation) and has mounted no displays at all. Every
|
|
229
|
-
// display-level wait above is silent in that state — there is nothing to be
|
|
230
|
-
// loading yet — so a capture taken then lands on a bare spinner.
|
|
231
|
-
//
|
|
232
|
-
// The `data-view-component-pending` half is the case the phase attribute cannot
|
|
233
|
-
// report: a view whose MODEL is initialized but whose lazily-imported React
|
|
234
|
-
// component has not arrived, so ViewContainer publishes a non-loading phase over
|
|
235
|
-
// a body that is still ViewWrapper's Suspense spinner. A session loaded at page
|
|
236
|
-
// load has usually won that race by the time anything else settles; a view
|
|
237
|
-
// CLICKED into existence fetches its chunk only then, and its frame is the one a
|
|
238
|
-
// launch-dialog figure is about.
|
|
239
|
-
//
|
|
240
|
-
// NOT best-effort, unlike its neighbours: a view that never leaves `loading` has
|
|
241
|
-
// no content to fall through to, so the timeout IS the diagnosis and the caller
|
|
242
|
-
// should surface it.
|
|
243
|
-
export function waitForViewPhases(page, timeoutMs) {
|
|
244
|
-
return page.waitForFunction(() => document.querySelector('[data-view-phase="loading"]') === null &&
|
|
245
|
-
document.querySelector('[data-view-component-pending]') === null, { timeout: timeoutMs, polling: 500 });
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Everything the app publishes to say it is working, as one selector.
|
|
249
|
-
*
|
|
250
|
-
* All four are attributes a component sets deliberately, which is the whole
|
|
251
|
-
* point: the alternative — scanning rendered text for /^loading/ and resolving
|
|
252
|
-
* `getComputedStyle` to decide whether each match is on screen — makes a
|
|
253
|
-
* reworded message, a translation or an opacity animation change the answer.
|
|
254
|
-
*
|
|
255
|
-
* `loading-overlay` the view-level scrim, present in every build
|
|
256
|
-
* `data-busy` LoadingEllipses, which is what the app renders
|
|
257
|
-
* wherever it tells a user it is working
|
|
258
|
-
* `data-display-phase` one display's own fetch, newer builds
|
|
259
|
-
* `data-view-phase` a view still resolving its assembly, newer builds
|
|
260
|
-
*/
|
|
261
|
-
export const BUSY_SELECTOR = [
|
|
262
|
-
'[data-testid="loading-overlay"]',
|
|
263
|
-
'[data-busy="true"]',
|
|
264
|
-
'[data-display-phase="loading"]',
|
|
265
|
-
'[data-view-phase="loading"]',
|
|
266
|
-
].join(', ');
|
|
267
|
-
/**
|
|
268
|
-
* Is the app doing anything right now?
|
|
269
|
-
*
|
|
270
|
-
* Serialized into the page, so it declares everything it uses. Two sources: the
|
|
271
|
-
* selector above, and the live session model's own per-display status, which is
|
|
272
|
-
* the only PER-DISPLAY signal a build with no readiness attributes has left.
|
|
273
|
-
* Both are contracts rather than renderings — a model field and a set of data
|
|
274
|
-
* attributes — so neither moves when the UI is restyled.
|
|
275
|
-
*
|
|
276
|
-
* Exported so a test can call the real function rather than a copy of it.
|
|
277
|
-
*/
|
|
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(', ');
|
|
285
|
-
if (document.querySelector(busySelector)) {
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
const session = globalThis
|
|
289
|
-
.JBrowseSession;
|
|
290
|
-
const tracksOf = (c) => (c.tracks ?? []).flatMap(t => t.displays ?? []);
|
|
291
|
-
const displaysOf = (v) => [
|
|
292
|
-
...tracksOf(v),
|
|
293
|
-
...(v.trackContainers ?? []).flatMap(tracksOf),
|
|
294
|
-
...(v.views ?? []).flatMap(displaysOf),
|
|
295
|
-
];
|
|
296
|
-
return (session?.views ?? [])
|
|
297
|
-
.flatMap(displaysOf)
|
|
298
|
-
.some(d => (d.message ?? d.statusMessage ?? '').trim() !== '');
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Wait until the app has been idle for an unbroken stretch.
|
|
302
|
-
*
|
|
303
|
-
* The gate for a build that publishes no readiness attributes, where every
|
|
304
|
-
* other wait in this module is an assertion about an absent selector and so
|
|
305
|
-
* cannot fail. Absence answers "is it working NOW"; a capture needs "has it
|
|
306
|
-
* finished", and the two differ in both directions:
|
|
307
|
-
*
|
|
308
|
-
* BEFORE the work starts. Measured on jbrowse.org/code/jb2/latest with two
|
|
309
|
-
* remote tracks: the session reports both tracks open at ~2.5s and the
|
|
310
|
-
* loading overlay does not go up until ~3.5s. Every absence-based gate passes
|
|
311
|
-
* during that second, over an app that has drawn nothing.
|
|
312
|
-
*
|
|
313
|
-
* BETWEEN two pieces of work. A track that finishes one fetch and starts the
|
|
314
|
-
* next is momentarily idle, and a single-sample gate takes it.
|
|
315
|
-
*
|
|
316
|
-
* Requiring the idle to HOLD closes both without needing to know which signals
|
|
317
|
-
* a given build has. Polled from node rather than in-page: chrome throttles
|
|
318
|
-
* in-page timers and rAF once the tab is not visible, which is the state a
|
|
319
|
-
* headless capture sits in.
|
|
320
|
-
*
|
|
321
|
-
* Returns false on timeout rather than throwing, like its neighbours — the
|
|
322
|
-
* caller decides whether a page that never went quiet is a failure or a slow
|
|
323
|
-
* page worth capturing anyway.
|
|
324
|
-
*/
|
|
325
|
-
export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000, pollMs = 250, busyWindowMs = 0, } = {}) {
|
|
326
|
-
const start = Date.now();
|
|
327
|
-
const deadline = start + timeout;
|
|
328
|
-
let quietSince;
|
|
329
|
-
let seenBusy = busyWindowMs === 0;
|
|
330
|
-
while (Date.now() < deadline) {
|
|
331
|
-
// A page that navigates or closes under us fails the evaluate; treat that
|
|
332
|
-
// as busy and let the deadline decide, rather than reporting quiet.
|
|
333
|
-
const busy = await page.evaluate(isPageBusyInPage).catch(() => true);
|
|
334
|
-
const now = Date.now();
|
|
335
|
-
if (busy) {
|
|
336
|
-
seenBusy = true;
|
|
337
|
-
quietSince = undefined;
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
if (!seenBusy && now - start >= busyWindowMs) {
|
|
341
|
-
// never went busy within the window: nothing to wait out
|
|
342
|
-
seenBusy = true;
|
|
343
|
-
quietSince = now;
|
|
344
|
-
}
|
|
345
|
-
if (seenBusy) {
|
|
346
|
-
quietSince ??= now;
|
|
347
|
-
if (now - quietSince >= quietMs) {
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
await delay(pollMs);
|
|
353
|
-
}
|
|
354
|
-
return false;
|
|
355
|
-
}
|
|
356
|
-
/**
|
|
357
|
-
* Wait for the app to say it has finished.
|
|
358
|
-
*
|
|
359
|
-
* One selector, no chain: `[data-app-phase="ready"]` is rendered by the session
|
|
360
|
-
* itself, so it cannot be satisfied before the app exists. Returns false if the
|
|
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.
|
|
363
|
-
*/
|
|
364
|
-
export function waitForAppReady(page, { timeout = 30000 } = {}) {
|
|
365
|
-
return settled(page.waitForSelector(APP_READY, { timeout, visible: false }));
|
|
366
|
-
}
|
|
367
|
-
/** Whether this build publishes the app-level readiness marker at all. */
|
|
368
|
-
export function hasAppReadyMarker(page) {
|
|
369
|
-
return page.evaluate(() => document.querySelector('[data-app-phase]') !== null);
|
|
370
|
-
}
|
|
371
|
-
/**
|
|
372
|
-
* Wait out work an INTERACTION started: `ready`, and still ready a beat later.
|
|
373
|
-
*
|
|
374
|
-
* The gate for after a click, a keystroke or a resize, where `waitForAppReady`
|
|
375
|
-
* alone is not one. A page that is still LOADING starts at `loading` and the
|
|
376
|
-
* transition into `ready` is the app finishing, so one read of the selector
|
|
377
|
-
* answers it. After an interaction the app is already `ready` — it was finished a
|
|
378
|
-
* moment ago — and stays that way until the click's work registers, so the same
|
|
379
|
-
* read returns instantly, on the pre-click frame.
|
|
380
|
-
*
|
|
381
|
-
* What it replaces is a fixed sleep, which is wrong in both directions and only
|
|
382
|
-
* ever caught in one: too short captures the work in progress, and the figure it
|
|
383
|
-
* produces looks finished, while too long is dead time on every run.
|
|
384
|
-
*
|
|
385
|
-
* **Not "seen busy, then ready"**, which is the obvious shape and was built and
|
|
386
|
-
* measured first. Waiting for `loading` to appear before accepting `ready` needs
|
|
387
|
-
* a cap, since an interaction that only opened a menu never makes the app busy at
|
|
388
|
-
* all — and on the figure spec it was measured against
|
|
389
|
-
* (`search_feature_highlight`) the app was never seen busy either, because that
|
|
390
|
-
* spec's own selector wait had already outlasted the redraw. The busy window then
|
|
391
|
-
* ran to its 2s cap having watched nothing, costing more than the 1.2s sleep it
|
|
392
|
-
* replaced. Requiring the idle to HOLD costs the hold and no more, and catches
|
|
393
|
-
* the same late-starting work.
|
|
394
|
-
*
|
|
395
|
-
* Polled from Node rather than by `page.waitForSelector`, for the reason
|
|
396
|
-
* `waitForQuietPeriod` is: chrome throttles in-page timers and rAF once the tab
|
|
397
|
-
* is not visible, which is exactly the state a headless capture sits in.
|
|
398
|
-
*
|
|
399
|
-
* Falls back to the quiet period on a build too old for the marker, rather than
|
|
400
|
-
* passing instantly. A no-op that reports success is how a spec that dropped its
|
|
401
|
-
* sleep for this ends up capturing the frame the sleep was there to avoid.
|
|
402
|
-
*/
|
|
403
|
-
export async function waitForAppSettled(page, { timeout = 30000, holdMs = APP_SETTLED_HOLD_MS, pollMs = 250, } = {}) {
|
|
404
|
-
if (!(await hasAppReadyMarker(page))) {
|
|
405
|
-
return waitForQuietPeriod(page, { quietMs: holdMs, timeout, pollMs });
|
|
406
|
-
}
|
|
407
|
-
const deadline = Date.now() + timeout;
|
|
408
|
-
let readySince;
|
|
409
|
-
while (Date.now() < deadline) {
|
|
410
|
-
// a page that navigates or closes under us fails the evaluate; treat that as
|
|
411
|
-
// not-ready and let the deadline decide
|
|
412
|
-
const ready = await page
|
|
413
|
-
.evaluate(selector => document.querySelector(selector) !== null, APP_READY)
|
|
414
|
-
.catch(() => false);
|
|
415
|
-
const now = Date.now();
|
|
416
|
-
if (ready) {
|
|
417
|
-
readySince ??= now;
|
|
418
|
-
if (now - readySince >= holdMs) {
|
|
419
|
-
return true;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
else {
|
|
423
|
-
readySince = undefined;
|
|
424
|
-
}
|
|
425
|
-
await delay(pollMs);
|
|
426
|
-
}
|
|
427
|
-
return false;
|
|
99
|
+
export async function waitForAppSettled(page, options) {
|
|
100
|
+
return (await appSettledBlocker(page, options)) === undefined;
|
|
428
101
|
}
|
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.10",
|
|
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.12.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@jbrowse/core": "^5.0.0-beta.
|
|
37
|
+
"@jbrowse/core": "^5.0.0-beta.10"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|