@jbrowse/capture 4.3.0 → 5.0.0-beta.2

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/esm/args.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const NUMERIC = new Set(['width', 'height', 'scale', 'timeout', 'settle']);
2
+ const POSITIVE = new Set(['width', 'height', 'scale']);
2
3
  const FLAGS = new Set([
3
4
  'fullPage',
4
5
  'headed',
@@ -53,6 +54,10 @@ export function parseArgs(argv) {
53
54
  throw new Error(`unknown flag "${arg}"`);
54
55
  }
55
56
  if (FLAGS.has(name)) {
57
+ // `--fullPage=false` used to set the flag true, silently.
58
+ if (eq !== -1) {
59
+ throw new Error(`--${name} is a flag and takes no value; omit it to leave it off`);
60
+ }
56
61
  out[name] = true;
57
62
  continue;
58
63
  }
@@ -69,6 +74,11 @@ export function parseArgs(argv) {
69
74
  if (!Number.isFinite(n)) {
70
75
  throw new Error(`--${name} needs a number, got "${value}"`);
71
76
  }
77
+ // A zero-size viewport or scale otherwise fails much later, inside
78
+ // puppeteer, with an error naming neither the flag nor the value.
79
+ if (POSITIVE.has(name) && n <= 0) {
80
+ throw new Error(`--${name} needs a positive number, got "${value}"`);
81
+ }
72
82
  out[name] = n;
73
83
  }
74
84
  else {
package/esm/capture.d.ts CHANGED
@@ -1,70 +1,6 @@
1
- import type { Instrumentation, PendingDisplay, SessionExpectations } from './sessionGate.ts';
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
- /** Capture the whole scrollable page rather than the viewport. */
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 { delay, hasAppReadyMarker, waitForAppReady, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
7
- // A build with no per-display paint attributes gives no signal between "the
8
- // overlay cleared" and "the canvas has pixels on it", so the only thing left is
9
- // to wait. Sized from the measured gap on jbrowse.org/code/jb2/latest between
10
- // the overlay clearing and the tracks being fully drawn.
11
- const LEGACY_PAINT_SETTLE_MS = 1500;
12
- // ...and the gap at the OTHER end, which is the one that lands a capture on an
13
- // app that has drawn nothing at all. Measured on the same instance with two
14
- // remote tracks: the session gate is satisfied at ~2.5s, and the loading overlay
15
- // — the only remaining signal there — does not go up until ~3.5s. An
16
- // instrumented build has no such window: `data-display-phase` appears in the
17
- // same frame as the display it belongs to.
18
- //
19
- // So on an uninstrumented build the app has to be seen to START working, and
20
- // then hold still, before it counts as finished. The window is how long the
21
- // first indicator gets to appear before "nothing to wait for" is the better
22
- // reading; the quiet is how long the idle then has to hold.
23
- const LEGACY_BUSY_WINDOW_MS = 4000;
24
- const LEGACY_QUIET_MS = 2000;
25
- /**
26
- * Wait until a JBrowse page has finished loading AND finished drawing.
27
- *
28
- * One positive gate, then the negative ones. The order is the whole point: the
29
- * DOM waits all pass on a page that has not started yet, so without the session
30
- * gate in front the chain returns in under a second (see sessionGate.ts). After
31
- * it, each stage is only meaningful once the previous has passed — a view still
32
- * resolving its assembly has mounted no displays, and a display that has mounted
33
- * but not fetched has nothing to be drawn yet.
34
- */
35
- export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownloads = true, settleMs = 0, expectSession = true, assembly, trackIds, allowUnsettled = false, } = {}) {
36
- const unsettled = [];
37
- const stage = async (name, work) => {
38
- if (!(await work)) {
39
- unsettled.push(name);
40
- }
41
- };
42
- // The two hard waits reject with puppeteer's own `Waiting failed: Nms
43
- // exceeded`, which names neither the stage nor the selector — the whole
44
- // failure mode this module exists to avoid, arriving as an error message
45
- // instead of as a blank image. Say which gate it was and what to do.
46
- const required = async (name, work) => {
47
- try {
48
- return await work;
49
- }
50
- catch {
51
- throw new Error(`gave up waiting after ${timeout}ms: ${name}. Raise the timeout if the ` +
52
- 'page is merely slow; if it never finishes, open the same URL in a ' +
53
- 'browser — this gate has no content to fall through to.');
54
- }
55
- };
56
- // 0. the session exists and holds what was asked for. Positive, and throws.
57
- if (expectSession) {
58
- await waitForSession(page, { assembly, trackIds, timeout });
59
- }
60
- // 1. THE WHOLE ANSWER, on any build that has it: the session renders
61
- // `[data-app-phase="ready"]` when no view is resolving an assembly and no
62
- // display is fetching. It is positive, so unlike everything below it
63
- // cannot be satisfied by an app that has not started, and there is nothing
64
- // to assemble — wait for the selector and stop.
65
- //
66
- // Everything after this point is the fallback for a deployment older than
67
- // the marker, and can be deleted the day the oldest supported build has
68
- // it.
69
- if (await hasAppReadyMarker(page)) {
70
- const ready = await waitForAppReady(page, { timeout });
71
- if (!ready) {
72
- unsettled.push('the app never reported itself ready');
73
- }
74
- // One thing the marker does not answer, so this stage stays even here: the
75
- // marker is about WORK, and a display whose fetch failed is not working. It
76
- // reads `ready` over an error banner, which is a correct answer to a
77
- // different question than a capture is asking. `data-display-drawn` is the
78
- // stricter gate — the two comparative canvases publish it from `settled`,
79
- // which holds an error open deliberately so a golden regenerated during an
80
- // outage fails here instead of absorbing the banner as expected output.
81
- // Ordered after the marker rather than instead of it, which is what makes an
82
- // absence meaningful (see waitForDisplaysDone), and free on a page that has
83
- // no such canvas.
84
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
85
- if (settleMs > 0) {
86
- await delay(settleMs);
87
- }
88
- const pendingStates = await pendingDisplayStates(page);
89
- const report = {
90
- pending: pendingStates.map(d => d.name),
91
- pendingStates,
92
- paintContract: await hasPaintContract(page),
93
- unsettled,
94
- instrumentation: await readInstrumentation(page),
95
- appMarker: true,
96
- };
97
- if (!allowUnsettled && unsettled.length > 0) {
98
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
99
- }
100
- return report;
101
- }
102
- // 1b. what an older build can be asked instead. On one that publishes none of
103
- // these, the stages below are not gates at all: they are assertions about
104
- // absent attributes that no page can fail.
105
- const instrumentation = await readInstrumentation(page);
106
- const instrumented = instrumentation.displayPhase || instrumentation.displayDrawn;
107
- // A page with no tracks open has nothing to load, so the quiet gate below
108
- // would be a fixed sleep for an import form or a menu shot.
109
- const summary = await readSessionSummary(page);
110
- const needsQuietGate = !instrumented && (summary?.trackIds.length ?? 0) > 0;
111
- // 2. the view has an assembly and its React component has arrived. Also not
112
- // best-effort: a view stuck here has no content to fall through to.
113
- await required('a view never left its loading phase (still resolving its assembly, or its ' +
114
- 'lazily-imported component never arrived)', waitForViewPhases(page, timeout));
115
- // 2. no track is still fetching. One call, two outcomes: the overlay half is
116
- // required (an overlay that never clears is a fetch that never finished,
117
- // with nothing behind it) and throws; the "Downloading…" half is
118
- // best-effort and comes back as the boolean.
119
- const downloadsSettled = await required('the loading overlay never cleared (a track fetch never finished)', waitForLoadingComplete(page, { timeout, waitForDownloads }));
120
- if (!downloadsSettled) {
121
- unsettled.push('a track was still downloading');
122
- }
123
- await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
124
- // 3. no display is still pending its first paint
125
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
126
- // 4. no visible "Loading…/Rendering…/Computing…" text remains, which is how
127
- // the views that publish no phase attribute report themselves
128
- await stage('a "Loading…/Rendering…" label was still on screen', waitForQuiescent(page, { timeout }));
129
- // 5. and on a build that answered none of the above, the one gate that does
130
- // not depend on an attribute existing: the app has to hold still. Every
131
- // stage before this one passed the moment it was asked, so without it the
132
- // chain returns while the first fetch is still being set up.
133
- if (needsQuietGate) {
134
- await stage('the app never went quiet for ' +
135
- `${LEGACY_QUIET_MS}ms (this build publishes no readiness attributes, ` +
136
- 'so being seen to work and then stop is the only finished signal there ' +
137
- 'is)', waitForQuietPeriod(page, {
138
- quietMs: LEGACY_QUIET_MS,
139
- busyWindowMs: LEGACY_BUSY_WINDOW_MS,
140
- timeout,
141
- }));
142
- }
143
- const paintContract = await hasPaintContract(page);
144
- if (!paintContract) {
145
- await delay(LEGACY_PAINT_SETTLE_MS);
146
- }
147
- if (settleMs > 0) {
148
- await delay(settleMs);
149
- }
150
- const pendingStates = await pendingDisplayStates(page);
151
- const report = {
152
- pending: pendingStates.map(d => d.name),
153
- pendingStates,
154
- paintContract,
155
- unsettled,
156
- instrumentation,
157
- appMarker: false,
158
- };
159
- if (!allowUnsettled && unsettled.length > 0) {
160
- // Throwing is the point. Each of these stages swallows its own timeout so a
161
- // slow page is not failed for being slow, which historically meant the run
162
- // ended with an image and an exit code of 0 whether it had settled or not.
163
- // A caller that genuinely wants the frame anyway asks for it by name.
164
- throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
165
- }
166
- return report;
167
- }
168
- /**
169
- * What a timed-out wait says. The stage names alone were the whole message, and
170
- * they name the QUESTION rather than the answer — "a display never reported its
171
- * first paint" reads identically for a slow fetch, a failed one and a display
172
- * that never had a canvas to paint. Appending the census answers it, and the
173
- * `ready` case is the one that most changes what a reader does next: a longer
174
- * timeout is the fix for `loading` and never the fix for that.
175
- */
176
- function unsettledMessage(timeout, unsettled, pending) {
177
- return (`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ${pending.length > 0
178
- ? `Still unpainted: ${describePendingDisplays(pending)}. `
179
- : ''}Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ` +
180
- `capture the frame as it stands.`);
181
- }
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,7 +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, timeout, trackIds,
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,
196
27
  // Pulled out only to keep them out of `urlOptions`, which becomes the
197
28
  // query string. They reach the ready wait through the `...options` spread
198
29
  // below rather than from here.
@@ -229,18 +60,17 @@ export async function openJBrowse(options = {}) {
229
60
  // construction. Listing them by hand is what dropped `allowUnsettled`:
230
61
  // it was declared, documented, recommended by this function's own timeout
231
62
  // message, and silently never forwarded, so `--allowUnsettled` did
232
- // nothing and a timing-out stage always threw. Only the two options
233
- // derived from the URL are overridden below.
63
+ // nothing and a timing-out stage always threw. Overridden below: the
64
+ // defaulted timeout, and the two options derived from the URL.
234
65
  ...options,
66
+ timeout,
235
67
  // A session spec's own assembly wins over the hub name. `--hub hg38
236
68
  // --session spec.json` where the spec opens something else is legitimate
237
69
  // (the hub is just supplying the config), and expecting the hub name there
238
70
  // would fail a capture that is entirely correct.
239
- assembly: urlOptions.assembly ??
240
- (urlOptions.session
241
- ? assemblyFromSession(urlOptions.session)
242
- : undefined) ??
243
- (urlOptions.session ? undefined : urlOptions.hub),
71
+ assembly: urlOptions.session
72
+ ? (urlOptions.assembly ?? assemblyFromSession(urlOptions.session))
73
+ : (urlOptions.assembly ?? urlOptions.hub),
244
74
  trackIds: trackIds ??
245
75
  urlOptions.tracks ??
246
76
  (urlOptions.session
@@ -251,8 +81,9 @@ export async function openJBrowse(options = {}) {
251
81
  }
252
82
  catch (error) {
253
83
  // A launch that got as far as a page and then failed still holds a Chrome
254
- // process; without this the caller has no handle to close it.
255
- await browser.close();
84
+ // process; without this the caller has no handle to close it. The close is
85
+ // guarded so its own failure cannot replace the error worth reporting.
86
+ await browser.close().catch(() => { });
256
87
  throw error;
257
88
  }
258
89
  }
@@ -262,9 +93,24 @@ export async function openJBrowse(options = {}) {
262
93
  */
263
94
  export async function captureJBrowse(options = {}) {
264
95
  const { out, fullPage = false, ...openOptions } = options;
96
+ if (out) {
97
+ // Before the browser launches: a missing parent directory otherwise fails
98
+ // at the screenshot, after the whole launch-navigate-wait cycle.
99
+ mkdirSync(dirname(out), { recursive: true });
100
+ }
265
101
  const { browser, page, url, ...report } = await openJBrowse(openOptions);
266
102
  try {
267
- const image = await page.screenshot({ path: out, fullPage });
103
+ if (fullPage) {
104
+ const viewport = page.viewport();
105
+ const pageHeight = await page.evaluate(() => Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)));
106
+ if (viewport && pageHeight > viewport.height) {
107
+ await page.setViewport({ ...viewport, height: pageHeight });
108
+ // The resize invalidates the raster and can start work (a display that
109
+ // grew gained rows to draw), so the frame has to settle again.
110
+ await waitForAppSettled(page, { timeout: openOptions.timeout });
111
+ }
112
+ }
113
+ const image = await page.screenshot({ path: out });
268
114
  return { url, image, ...report };
269
115
  }
270
116
  finally {
package/esm/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- export { captureJBrowse, openJBrowse, waitForJBrowseReady } from './capture.ts';
2
- export { PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.ts';
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, ReadyOptions, ReadyReport, } from './capture.ts';
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, waitForJBrowseReady } from './capture.js';
2
- export { PAINT_CONTRACT_NOTE, describePendingDisplays, hasPaintContract, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
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,190 @@
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';
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 two hard waits reject with puppeteer's own `Waiting failed: Nms
42
+ // exceeded`, which names neither the stage nor the selector — the whole
43
+ // failure mode this module exists to avoid, arriving as an error message
44
+ // instead of as a blank image. Say which gate it was and what to do.
45
+ const required = async (name, work) => {
46
+ try {
47
+ return await work;
48
+ }
49
+ catch {
50
+ throw new Error(`gave up waiting after ${timeout}ms: ${name}. Raise the timeout if the ` +
51
+ 'page is merely slow; if it never finishes, open the same URL in a ' +
52
+ 'browser — this gate has no content to fall through to.');
53
+ }
54
+ };
55
+ // 0. the session exists and holds what was asked for. Positive, and throws.
56
+ if (expectSession) {
57
+ await waitForSession(page, { assembly, trackIds, timeout });
58
+ }
59
+ // 1. THE WHOLE ANSWER, on any build that has it: the session renders
60
+ // `[data-app-phase="ready"]` when no view is resolving an assembly and no
61
+ // 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.
64
+ //
65
+ // Everything after this point is the fallback for a deployment older than
66
+ // the marker, and can be deleted the day the oldest supported build has
67
+ // it.
68
+ if (await hasAppReadyMarker(page)) {
69
+ const ready = await waitForAppReady(page, { timeout });
70
+ if (!ready) {
71
+ unsettled.push('the app never reported itself ready');
72
+ }
73
+ // One thing the marker does not answer, so this stage stays even here: the
74
+ // marker is about WORK, and a display whose fetch failed is not working. It
75
+ // reads `ready` over an error banner, which is a correct answer to a
76
+ // different question than a capture is asking. `data-display-drawn` is the
77
+ // stricter gate — the two comparative canvases publish it from `settled`,
78
+ // 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.
85
+ if (ready) {
86
+ await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
87
+ }
88
+ if (settleMs > 0) {
89
+ await delay(settleMs);
90
+ }
91
+ const pendingStates = await pendingDisplayStates(page);
92
+ const report = {
93
+ pending: pendingStates.map(d => d.name),
94
+ pendingStates,
95
+ paintContract: await hasPaintContract(page),
96
+ unsettled,
97
+ instrumentation: await readInstrumentation(page),
98
+ appMarker: true,
99
+ };
100
+ if (!allowUnsettled && unsettled.length > 0) {
101
+ throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
102
+ }
103
+ return report;
104
+ }
105
+ // 1b. what an older build can be asked instead. On one that publishes none of
106
+ // these, the stages below are not gates at all: they are assertions about
107
+ // absent attributes that no page can fail.
108
+ const instrumentation = await readInstrumentation(page);
109
+ const instrumented = instrumentation.displayPhase || instrumentation.displayDrawn;
110
+ // A page with no tracks open has nothing to load, so the quiet gate below
111
+ // would be a fixed sleep for an import form or a menu shot. No summary at all
112
+ // — expectSession: false, where these reads race the boot — is the opposite
113
+ // case: nothing has been ruled out, and reading "unknown" as "no tracks" here
114
+ // is what let the whole chain pass over an app that had not started, since
115
+ // every stage below the gate is an absence an empty page satisfies.
116
+ const summary = await readSessionSummary(page);
117
+ const needsQuietGate = !instrumented && (summary === undefined || summary.trackIds.length > 0);
118
+ // 2. the view has an assembly and its React component has arrived. Also not
119
+ // best-effort: a view stuck here has no content to fall through to.
120
+ await required('a view never left its loading phase (still resolving its assembly, or its ' +
121
+ 'lazily-imported component never arrived)', waitForViewPhases(page, timeout));
122
+ // 3. no track is still fetching: an overlay that never clears is a fetch
123
+ // that never finished, with nothing behind it, so this one throws too.
124
+ await required('the loading overlay never cleared (a track fetch never finished)', waitForLoadingComplete(page, { timeout }));
125
+ await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
126
+ // 4. no display is still pending its first paint
127
+ await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
128
+ // 5. nothing still reports itself busy: adapter "Downloading…" status on the
129
+ // session model, which can outlive the overlay, and the visible
130
+ // "Loading…/Rendering…" labels of the views that publish no phase
131
+ // attribute. One predicate answers both, so it is one stage — it used to
132
+ // run twice, once as waitForLoadingComplete's download half and once
133
+ // under its own name, and a slow page paid the same timeout for each.
134
+ if (waitForDownloads) {
135
+ await stage('a track was still downloading, or a "Loading…/Rendering…" label was ' +
136
+ 'still on screen', waitForQuiescent(page, { timeout }));
137
+ }
138
+ // 6. and on a build that answered none of the above, the one gate that does
139
+ // not depend on an attribute existing: the app has to hold still. Every
140
+ // stage before this one passed the moment it was asked, so without it the
141
+ // chain returns while the first fetch is still being set up.
142
+ if (needsQuietGate) {
143
+ await stage('the app never went quiet for ' +
144
+ `${LEGACY_QUIET_MS}ms (this build publishes no readiness attributes, ` +
145
+ 'so being seen to work and then stop is the only finished signal there ' +
146
+ 'is)', waitForQuietPeriod(page, {
147
+ quietMs: LEGACY_QUIET_MS,
148
+ busyWindowMs: LEGACY_BUSY_WINDOW_MS,
149
+ timeout,
150
+ }));
151
+ }
152
+ const paintContract = await hasPaintContract(page);
153
+ if (!paintContract) {
154
+ await delay(LEGACY_PAINT_SETTLE_MS);
155
+ }
156
+ if (settleMs > 0) {
157
+ await delay(settleMs);
158
+ }
159
+ const pendingStates = await pendingDisplayStates(page);
160
+ const report = {
161
+ pending: pendingStates.map(d => d.name),
162
+ pendingStates,
163
+ paintContract,
164
+ unsettled,
165
+ instrumentation,
166
+ appMarker: false,
167
+ };
168
+ if (!allowUnsettled && unsettled.length > 0) {
169
+ // Throwing is the point. Each of these stages swallows its own timeout so a
170
+ // slow page is not failed for being slow, which historically meant the run
171
+ // ended with an image and an exit code of 0 whether it had settled or not.
172
+ // A caller that genuinely wants the frame anyway asks for it by name.
173
+ throw new Error(unsettledMessage(timeout, unsettled, pendingStates));
174
+ }
175
+ return report;
176
+ }
177
+ /**
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.
184
+ */
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.`);
190
+ }
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
- // an entry may be a bare id or `{trackId, displaySnapshot}`; an object
48
- // without a trackId is malformed rather than a track to expect, so it
49
- // drops out here instead of gating on undefined
50
- ...(view.tracks ?? [])
51
- .map(t => (typeof t === 'string' ? t : t.trackId))
52
- .filter(id => id !== undefined),
53
+ // 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);
@@ -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[];
@@ -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
- const summary = await readSessionSummary(page);
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.evaluate(() => [
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.js CHANGED
@@ -290,7 +290,7 @@ export function isPageBusyInPage() {
290
290
  const tracksOf = (c) => (c.tracks ?? []).flatMap(t => t.displays ?? []);
291
291
  const displaysOf = (v) => [
292
292
  ...tracksOf(v),
293
- ...(v.trackContainers ?? []).flatMap(tracksOf),
293
+ ...(v.trackContainers ?? v.levels ?? []).flatMap(tracksOf),
294
294
  ...(v.views ?? []).flatMap(displaysOf),
295
295
  ];
296
296
  return (session?.views ?? [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/capture",
3
- "version": "4.3.0",
3
+ "version": "5.0.0-beta.2",
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",
@@ -34,7 +34,7 @@
34
34
  "puppeteer": "^25.9.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@jbrowse/core": "^4.3.0"
37
+ "@jbrowse/core": "^5.0.0-beta.2"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"