@jbrowse/capture 5.0.0-beta.2 → 5.0.0-beta.4

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 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
- Every readiness signal JBrowse publishes is **negative**: no loading overlay, no
23
- display in its loading phase, no unpainted canvas. All of them therefore pass on
24
- a page whose JavaScript has not started yet. Measured against
25
- `jbrowse.org/code/jb2/latest`:
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
- Three fields on a successful capture:
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 does not on the current released build, which is
105
- what every genomes.jbrowse.org link opens; there, `pending: []` means "cannot
106
- tell", not "all done", and the CLI says so. A page with no tracks open reports
107
- true — there is nothing to measure, which is not the same as being unable to.
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,5 +1,6 @@
1
1
  const NUMERIC = new Set(['width', 'height', 'scale', 'timeout', 'settle']);
2
- const POSITIVE = new Set(['width', 'height', 'scale']);
2
+ const POSITIVE = new Set(['width', 'height', 'scale', 'timeout']);
3
+ const NON_NEGATIVE = new Set(['settle']);
3
4
  const FLAGS = new Set([
4
5
  'fullPage',
5
6
  'headed',
@@ -74,11 +75,15 @@ export function parseArgs(argv) {
74
75
  if (!Number.isFinite(n)) {
75
76
  throw new Error(`--${name} needs a number, got "${value}"`);
76
77
  }
77
- // A zero-size viewport or scale otherwise fails much later, inside
78
- // puppeteer, with an error naming neither the flag nor the value.
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.
79
81
  if (POSITIVE.has(name) && n <= 0) {
80
82
  throw new Error(`--${name} needs a positive number, got "${value}"`);
81
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
+ }
82
87
  out[name] = n;
83
88
  }
84
89
  else {
package/esm/capture.js CHANGED
@@ -24,10 +24,6 @@ export async function openJBrowse(options = {}) {
24
24
  // the same budget as the wait stages — puppeteer's own goto default is 30s,
25
25
  // half of what a caller passing nothing was told each stage would get.
26
26
  timeout = 60000, trackIds,
27
- // Pulled out only to keep them out of `urlOptions`, which becomes the
28
- // query string. They reach the ready wait through the `...options` spread
29
- // below rather than from here.
30
- waitForDownloads: _waitForDownloads, settleMs: _settleMs, expectSession: _expectSession, allowUnsettled: _allowUnsettled,
31
27
  // `assembly` is deliberately NOT pulled out here: it is both a URL option
32
28
  // (which assembly to open) and the session gate's expectation (which
33
29
  // assembly must end up open), and destructuring it for the second use would
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
- const res = await fetch(url);
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/ready.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describePendingDisplays, hasPaintContract, pendingDisplayStates, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
2
- import { delay, hasAppReadyMarker, waitForAppReady, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
2
+ import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
3
3
  // The whole ready chain, in a module that touches puppeteer only through its
4
4
  // types — capture.ts holds the runtime import (launching a browser), which is
5
5
  // what keeps the chain callable under jest against a stubbed Page.
@@ -38,6 +38,18 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
38
38
  unsettled.push(name);
39
39
  }
40
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
+ };
41
53
  // The two hard waits reject with puppeteer's own `Waiting failed: Nms
42
54
  // exceeded`, which names neither the stage nor the selector — the whole
43
55
  // failure mode this module exists to avoid, arriving as an error message
@@ -59,16 +71,20 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
59
71
  // 1. THE WHOLE ANSWER, on any build that has it: the session renders
60
72
  // `[data-app-phase="ready"]` when no view is resolving an assembly and no
61
73
  // display is fetching. It is positive, so unlike everything below it
62
- // cannot be satisfied by an app that has not started, and there is nothing
63
- // to assemble — wait for the selector and stop.
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.
64
80
  //
65
81
  // Everything after this point is the fallback for a deployment older than
66
82
  // the marker, and can be deleted the day the oldest supported build has
67
83
  // it.
68
84
  if (await hasAppReadyMarker(page)) {
69
- const ready = await waitForAppReady(page, { timeout });
85
+ const ready = await waitForAppSettled(page, { timeout });
70
86
  if (!ready) {
71
- unsettled.push('the app never reported itself ready');
87
+ unsettled.push('the app never held itself ready');
72
88
  }
73
89
  // One thing the marker does not answer, so this stage stays even here: the
74
90
  // marker is about WORK, and a display whose fetch failed is not working. It
@@ -76,15 +92,16 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
76
92
  // different question than a capture is asking. `data-display-drawn` is the
77
93
  // stricter gate — the two comparative canvases publish it from `settled`,
78
94
  // which holds an error open deliberately so a golden regenerated during an
79
- // outage fails here instead of absorbing the banner as expected output.
80
- // Ordered after the marker rather than instead of it, which is what makes an
81
- // absence meaningful (see waitForDisplaysDone), and free on a page that has
82
- // no such canvas. Skipped when the marker itself timed out: the paint wait
83
- // would spend a second full timeout on a page already known unsettled, and
84
- // the census below reports the unpainted displays either way.
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.
85
101
  if (ready) {
86
102
  await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
87
103
  }
104
+ await reportUnpainted();
88
105
  if (settleMs > 0) {
89
106
  await delay(settleMs);
90
107
  }
@@ -98,7 +115,7 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
98
115
  appMarker: true,
99
116
  };
100
117
  if (!allowUnsettled && unsettled.length > 0) {
101
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
118
+ throw new Error(unsettledMessage(timeout, unsettled));
102
119
  }
103
120
  return report;
104
121
  }
@@ -125,6 +142,7 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
125
142
  await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
126
143
  // 4. no display is still pending its first paint
127
144
  await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
145
+ await reportUnpainted();
128
146
  // 5. nothing still reports itself busy: adapter "Downloading…" status on the
129
147
  // session model, which can outlive the overlay, and the visible
130
148
  // "Loading…/Rendering…" labels of the views that publish no phase
@@ -170,21 +188,20 @@ export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownlo
170
188
  // slow page is not failed for being slow, which historically meant the run
171
189
  // ended with an image and an exit code of 0 whether it had settled or not.
172
190
  // A caller that genuinely wants the frame anyway asks for it by name.
173
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
191
+ throw new Error(unsettledMessage(timeout, unsettled));
174
192
  }
175
193
  return report;
176
194
  }
177
195
  /**
178
- * What a timed-out wait says. The stage names alone were the whole message, and
179
- * they name the QUESTION rather than the answer — "a display never reported its
180
- * first paint" reads identically for a slow fetch, a failed one and a display
181
- * that never had a canvas to paint. Appending the census answers it, and the
182
- * `ready` case is the one that most changes what a reader does next: a longer
183
- * timeout is the fix for `loading` and never the fix for that.
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.
184
202
  */
185
- function unsettledMessage(timeout, unsettled, pending) {
186
- return (`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ${pending.length > 0
187
- ? `Still unpainted: ${describePendingDisplays(pending)}. `
188
- : ''}Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ` +
189
- `capture the frame as it stands.`);
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.');
190
207
  }
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. Two sources: the
110
- * selector above, and the live session model's own per-display status, which is
111
- * the only PER-DISPLAY signal a build with no readiness attributes has left.
112
- * Both are contracts rather than renderings — a model field and a set of data
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 to say it has finished.
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
- * One selector, no chain: `[data-app-phase="ready"]` is rendered by the session
169
- * itself, so it cannot be satisfied before the app exists. Returns false if the
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 for after a click, a keystroke or a resize, where `waitForAppReady`
182
- * alone is not one. A page that is still LOADING starts at `loading` and the
183
- * transition into `ready` is the app finishing, so one read of the selector
184
- * answers it. After an interaction the app is already `ready` — it was finished a
185
- * moment ago — and stays that way until the click's work registers, so the same
186
- * read returns instantly, on the pre-click frame.
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
- // 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
- //
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 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).
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
- // 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.
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.querySelector(selector) === null, { timeout: timeoutMs }, PENDING_DISPLAYS));
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: 500 });
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. 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
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
  }
@@ -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.evaluate(isPageBusyInPage).catch(() => true);
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
- // never went busy within the window: nothing to wait out
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 = now;
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 to say it has finished.
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
- * 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.
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 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.
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.2",
3
+ "version": "5.0.0-beta.4",
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.9.0"
34
+ "puppeteer": "^25.10.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@jbrowse/core": "^5.0.0-beta.2"
37
+ "@jbrowse/core": "^5.0.0-beta.4"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"