@jbrowse/capture 5.0.0-beta.3 → 5.0.0-beta.7

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/ready.js CHANGED
@@ -1,207 +1,73 @@
1
- import { describePendingDisplays, hasPaintContract, pendingDisplayStates, readInstrumentation, readSessionSummary, waitForSession, } from './sessionGate.js';
2
- import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForQuietPeriod, waitForViewPhases, } from './waits.js';
3
- // The whole ready chain, in a module that touches puppeteer only through its
4
- // types — capture.ts holds the runtime import (launching a browser), which is
5
- // what keeps the chain callable under jest against a stubbed Page.
6
- // A build with no per-display paint attributes gives no signal between "the
7
- // overlay cleared" and "the canvas has pixels on it", so the only thing left is
8
- // to wait. Sized from the measured gap on jbrowse.org/code/jb2/latest between
9
- // the overlay clearing and the tracks being fully drawn.
10
- const LEGACY_PAINT_SETTLE_MS = 1500;
11
- // ...and the gap at the OTHER end, which is the one that lands a capture on an
12
- // app that has drawn nothing at all. Measured on the same instance with two
13
- // remote tracks: the session gate is satisfied at ~2.5s, and the loading overlay
14
- // — the only remaining signal there — does not go up until ~3.5s. An
15
- // instrumented build has no such window: `data-display-phase` appears in the
16
- // same frame as the display it belongs to.
17
- //
18
- // So on an uninstrumented build the app has to be seen to START working, and
19
- // then hold still, before it counts as finished. The window is how long the
20
- // first indicator gets to appear before "nothing to wait for" is the better
21
- // reading; the quiet is how long the idle then has to hold.
22
- const LEGACY_BUSY_WINDOW_MS = 4000;
23
- const LEGACY_QUIET_MS = 2000;
1
+ import { describePendingDisplays, pendingDisplayStates, waitForSession, } from './sessionGate.js';
2
+ import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplaysDone, } from './waits.js';
24
3
  /**
25
4
  * Wait until a JBrowse page has finished loading AND finished drawing.
26
5
  *
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.
6
+ * Two gates, both positive, which is the property that matters — every other
7
+ * readiness signal the app publishes is an ABSENCE, and an absence is equally
8
+ * true of an app that has not started.
9
+ *
10
+ * 1. The census says the assembly and trackIds you asked for are open.
11
+ * 2. `[data-app-phase="ready"]` HOLDS: no view is resolving an assembly and no
12
+ * display is fetching, and still not a beat later. A display drops to
13
+ * `ready` in the gap between one fetch finishing and the debounced next one
14
+ * starting, so a single read takes that gap for the end.
15
+ *
16
+ * Then one negative gate that is only meaningful after them, and answers the
17
+ * question the marker does not: the marker is about WORK, and a display whose
18
+ * fetch failed is not working — it reads `ready` over an error banner.
19
+ * `data-display-drawn` is the stricter gate.
33
20
  */
34
- export async function waitForJBrowseReady(page, { timeout = 60000, waitForDownloads = true, settleMs = 0, expectSession = true, assembly, trackIds, allowUnsettled = false, } = {}) {
21
+ export async function waitForJBrowseReady(page, { timeout = 60000, settleMs = 0, assembly, trackIds, allowUnsettled = false, } = {}) {
35
22
  const unsettled = [];
36
- const stage = async (name, work) => {
37
- if (!(await work)) {
38
- unsettled.push(name);
39
- }
40
- };
41
- // The post-condition of the paint stage, and on a terminal display the ONLY
42
- // report of it: `waitForDisplaysDone` returns as soon as every pending display
43
- // is past `loading`, so an errored canvas that will never paint no longer
44
- // spends the whole timeout to arrive here. The phase is what a reader needs —
45
- // `error` is a banner, `ready` is a display claiming it finished without
46
- // drawing — so the entry carries the census rather than naming the question.
47
- const reportUnpainted = async () => {
48
- const pending = await pendingDisplayStates(page);
49
- if (pending.length > 0) {
50
- unsettled.push(`display(s) never painted: ${describePendingDisplays(pending)}`);
51
- }
52
- };
53
- // The two hard waits reject with puppeteer's own `Waiting failed: Nms
54
- // exceeded`, which names neither the stage nor the selector — the whole
55
- // failure mode this module exists to avoid, arriving as an error message
56
- // instead of as a blank image. Say which gate it was and what to do.
57
- const required = async (name, work) => {
58
- try {
59
- return await work;
60
- }
61
- catch {
62
- throw new Error(`gave up waiting after ${timeout}ms: ${name}. Raise the timeout if the ` +
63
- 'page is merely slow; if it never finishes, open the same URL in a ' +
64
- 'browser — this gate has no content to fall through to.');
65
- }
66
- };
67
23
  // 0. the session exists and holds what was asked for. Positive, and throws.
68
- if (expectSession) {
69
- await waitForSession(page, { assembly, trackIds, timeout });
24
+ await waitForSession(page, { assembly, trackIds, timeout });
25
+ // 1. and the build publishes the marker at all. Said out loud rather than
26
+ // worked around: a chain built from the remaining signals is satisfied by
27
+ // a page that has not started, so the fallback that used to run here
28
+ // reported success on an empty browser unless it was also given a
29
+ // seen-busy-then-quiet gate to compensate. One positive selector replaces
30
+ // all of it, and a build too old to have it needs a newer --instance, not
31
+ // a wait chain that cannot fail.
32
+ if (!(await hasAppReadyMarker(page))) {
33
+ throw new Error('this page publishes no [data-app-phase], so there is no positive signal ' +
34
+ 'that it has finished — every other readiness attribute is an absence ' +
35
+ 'an app that has not started also satisfies. Point --instance at a ' +
36
+ 'JBrowse build that publishes it.');
70
37
  }
71
- // 1. THE WHOLE ANSWER, on any build that has it: the session renders
72
- // `[data-app-phase="ready"]` when no view is resolving an assembly and no
73
- // display is fetching. It is positive, so unlike everything below it
74
- // cannot be satisfied by an app that has not started.
75
- //
76
- // Held, not read once — the same hold `jb.waitReady` requires, because a
77
- // display drops to `ready` in the gap between one fetch finishing and the
78
- // debounced next one starting. The marker is known to exist here, so
79
- // `waitForAppSettled`'s quiet-period fallback does not come into it.
80
- //
81
- // Everything after this point is the fallback for a deployment older than
82
- // the marker, and can be deleted the day the oldest supported build has
83
- // it.
84
- if (await hasAppReadyMarker(page)) {
85
- const ready = await waitForAppSettled(page, { timeout });
86
- if (!ready) {
87
- unsettled.push('the app never held itself ready');
88
- }
89
- // One thing the marker does not answer, so this stage stays even here: the
90
- // marker is about WORK, and a display whose fetch failed is not working. It
91
- // reads `ready` over an error banner, which is a correct answer to a
92
- // different question than a capture is asking. `data-display-drawn` is the
93
- // stricter gate — the two comparative canvases publish it from `settled`,
94
- // which holds an error open deliberately so a golden regenerated during an
95
- // outage fails on the census below instead of absorbing the banner as
96
- // expected output. Ordered after the marker rather than instead of it, which
97
- // is what makes an absence meaningful (see waitForDisplaysDone), and free on
98
- // a page that has no such canvas. Skipped when the marker itself timed out:
99
- // the paint wait would spend a second full timeout on a page already known
100
- // unsettled, and the census reports the unpainted displays either way.
101
- if (ready) {
102
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
103
- }
104
- await reportUnpainted();
105
- if (settleMs > 0) {
106
- await delay(settleMs);
107
- }
108
- const pendingStates = await pendingDisplayStates(page);
109
- const report = {
110
- pending: pendingStates.map(d => d.name),
111
- pendingStates,
112
- paintContract: await hasPaintContract(page),
113
- unsettled,
114
- instrumentation: await readInstrumentation(page),
115
- appMarker: true,
116
- };
117
- if (!allowUnsettled && unsettled.length > 0) {
118
- throw new Error(unsettledMessage(timeout, unsettled));
119
- }
120
- return report;
121
- }
122
- // 1b. what an older build can be asked instead. On one that publishes none of
123
- // these, the stages below are not gates at all: they are assertions about
124
- // absent attributes that no page can fail.
125
- const instrumentation = await readInstrumentation(page);
126
- const instrumented = instrumentation.displayPhase || instrumentation.displayDrawn;
127
- // A page with no tracks open has nothing to load, so the quiet gate below
128
- // would be a fixed sleep for an import form or a menu shot. No summary at all
129
- // — expectSession: false, where these reads race the boot — is the opposite
130
- // case: nothing has been ruled out, and reading "unknown" as "no tracks" here
131
- // is what let the whole chain pass over an app that had not started, since
132
- // every stage below the gate is an absence an empty page satisfies.
133
- const summary = await readSessionSummary(page);
134
- const needsQuietGate = !instrumented && (summary === undefined || summary.trackIds.length > 0);
135
- // 2. the view has an assembly and its React component has arrived. Also not
136
- // best-effort: a view stuck here has no content to fall through to.
137
- await required('a view never left its loading phase (still resolving its assembly, or its ' +
138
- 'lazily-imported component never arrived)', waitForViewPhases(page, timeout));
139
- // 3. no track is still fetching: an overlay that never clears is a fetch
140
- // that never finished, with nothing behind it, so this one throws too.
141
- await required('the loading overlay never cleared (a track fetch never finished)', waitForLoadingComplete(page, { timeout }));
142
- await stage('a display was still in its loading phase', waitForDisplayPhases(page, timeout));
143
- // 4. no display is still pending its first paint
144
- await stage('a display never reported its first paint', waitForDisplaysDone(page, timeout));
145
- await reportUnpainted();
146
- // 5. nothing still reports itself busy: adapter "Downloading…" status on the
147
- // session model, which can outlive the overlay, and the visible
148
- // "Loading…/Rendering…" labels of the views that publish no phase
149
- // attribute. One predicate answers both, so it is one stage — it used to
150
- // run twice, once as waitForLoadingComplete's download half and once
151
- // under its own name, and a slow page paid the same timeout for each.
152
- if (waitForDownloads) {
153
- await stage('a track was still downloading, or a "Loading…/Rendering…" label was ' +
154
- 'still on screen', waitForQuiescent(page, { timeout }));
38
+ const ready = await waitForAppSettled(page, { timeout });
39
+ if (!ready) {
40
+ unsettled.push('the app never held itself ready');
155
41
  }
156
- // 6. and on a build that answered none of the above, the one gate that does
157
- // not depend on an attribute existing: the app has to hold still. Every
158
- // stage before this one passed the moment it was asked, so without it the
159
- // chain returns while the first fetch is still being set up.
160
- if (needsQuietGate) {
161
- await stage('the app never went quiet for ' +
162
- `${LEGACY_QUIET_MS}ms (this build publishes no readiness attributes, ` +
163
- 'so being seen to work and then stop is the only finished signal there ' +
164
- 'is)', waitForQuietPeriod(page, {
165
- quietMs: LEGACY_QUIET_MS,
166
- busyWindowMs: LEGACY_BUSY_WINDOW_MS,
167
- timeout,
168
- }));
169
- }
170
- const paintContract = await hasPaintContract(page);
171
- if (!paintContract) {
172
- await delay(LEGACY_PAINT_SETTLE_MS);
42
+ // Skipped when the marker itself timed out: the paint wait would spend a
43
+ // second full timeout on a page already known unsettled, and the census below
44
+ // reports the unpainted displays either way. Free on a page with no canvas.
45
+ if (ready) {
46
+ const drawn = await waitForDisplaysDone(page, timeout);
47
+ if (!drawn) {
48
+ unsettled.push('a display never reported its first paint');
49
+ }
173
50
  }
174
51
  if (settleMs > 0) {
175
52
  await delay(settleMs);
176
53
  }
54
+ // The shutter-time census, taken ONCE and after the settle, so the throw and
55
+ // the report it ships with agree. Taken before it they did not: a display
56
+ // that painted during `settleMs` was absent from `pending` and still failed
57
+ // the run, while the CLI's own warning recommended raising `--settle` to fix
58
+ // exactly that.
177
59
  const pendingStates = await pendingDisplayStates(page);
178
- const report = {
179
- pending: pendingStates.map(d => d.name),
180
- pendingStates,
181
- paintContract,
182
- unsettled,
183
- instrumentation,
184
- appMarker: false,
185
- };
60
+ if (pendingStates.length > 0) {
61
+ unsettled.push(`display(s) never painted: ${describePendingDisplays(pendingStates)}`);
62
+ }
186
63
  if (!allowUnsettled && unsettled.length > 0) {
187
- // Throwing is the point. Each of these stages swallows its own timeout so a
188
- // slow page is not failed for being slow, which historically meant the run
189
- // ended with an image and an exit code of 0 whether it had settled or not.
190
- // A caller that genuinely wants the frame anyway asks for it by name.
191
- throw new Error(unsettledMessage(timeout, unsettled));
64
+ // Throwing is the point. Each stage swallows its own timeout so a slow page
65
+ // is not failed for being slow, which historically meant the run ended with
66
+ // an image and an exit code of 0 whether it had settled or not. A caller
67
+ // that genuinely wants the frame anyway asks for it by name.
68
+ throw new Error(`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ` +
69
+ 'Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ' +
70
+ 'capture the frame as it stands.');
192
71
  }
193
- return report;
194
- }
195
- /**
196
- * What a timed-out wait says. A stage name is the QUESTION rather than the
197
- * answer — "a display never reported its first paint" reads identically for a
198
- * slow fetch, a failed one and a display that never had a canvas to paint — so
199
- * `reportUnpainted` puts the census in the list beside it, and the `ready` case
200
- * is the one that most changes what a reader does next: a longer timeout is the
201
- * fix for `loading` and never the fix for that.
202
- */
203
- function unsettledMessage(timeout, unsettled) {
204
- return (`gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ` +
205
- 'Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ' +
206
- 'capture the frame as it stands.');
72
+ return { pending: pendingStates.map(d => d.name), pendingStates, unsettled };
207
73
  }
package/esm/session.js CHANGED
@@ -58,8 +58,18 @@ export function trackIdsFromSession(session) {
58
58
  ]);
59
59
  return collect(session.views);
60
60
  }
61
- // Full `?config=…&session=…&sessionName=…` query string. `config` is inserted
62
- // as-is (callers that point at a remote absolute URL pre-encode it themselves).
61
+ // Full `?config=…&session=…&sessionName=…` query string, for a caller that has
62
+ // an origin but no JBrowse instance URL to hand `jbrowseUrl`.
63
+ //
64
+ // Built with URLSearchParams, the same encoder and the same rule as
65
+ // `jbrowseUrl`: hand it the DECODED spec, because it encodes once itself. It
66
+ // used to interpolate `config` raw and ask callers to pre-encode it, which none
67
+ // of them did — harmless only for as long as no config URL contained a `?` or
68
+ // an `&`, at which point the link loads a truncated config and reports nothing.
63
69
  export function sessionSpecQuery({ config, session, sessionName = 'Screenshot', }) {
64
- return `?config=${config}&session=${encodeSessionSpec(session)}&sessionName=${encodeURIComponent(sessionName)}`;
70
+ return `?${new URLSearchParams({
71
+ config,
72
+ session: sessionSpecParam(session),
73
+ sessionName,
74
+ })}`;
65
75
  }
@@ -13,11 +13,15 @@ export interface SessionExpectations {
13
13
  /**
14
14
  * The census the app publishes beside its phase: `AppReadyMarker` renders
15
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.
16
+ * (JSON string arrays of what is open).
17
+ *
18
+ * One element answers the whole gate. Each view declares what it holds and
19
+ * `openViews`/`openTracks` reduce over those declarations (ADR-103), so nothing
20
+ * here has to know that a synteny view keeps its tracks on levels and its rows
21
+ * on sub-views — the duck-typed walk of `window.JBrowseSession` that used to
22
+ * live here, and drifted against the copy in `isPageBusyInPage`, is gone with
23
+ * the builds that needed it.
24
+ *
21
25
  * `scripts/readinessContract.test.ts` pins the attribute names against the
22
26
  * marker, since neither package may import the other.
23
27
  */
@@ -27,12 +31,12 @@ export interface SessionSummary {
27
31
  assemblies: string[];
28
32
  trackIds: string[];
29
33
  }
30
- /** What the page currently has open, or undefined if there is no session yet. */
34
+ /** What the page currently has open, or undefined if there is no census yet. */
31
35
  export declare function readSessionSummary(page: Page): Promise<SessionSummary | undefined>;
32
36
  export declare function readSessionSummaryInPage(): SessionSummary | undefined;
33
37
  /**
34
- * Wait until the session exists, every view reports itself initialized, and the
35
- * assembly and tracks that were asked for are actually open.
38
+ * Wait until the census exists and the assembly and tracks that were asked for
39
+ * are actually open.
36
40
  *
37
41
  * Throws on timeout rather than proceeding. A config URL that 404s, a trackId
38
42
  * that does not exist in the config, and an assembly name that does not match
@@ -43,58 +47,11 @@ export declare function readSessionSummaryInPage(): SessionSummary | undefined;
43
47
  export declare function waitForSession(page: Page, { assembly, trackIds, timeout, }?: SessionExpectations & {
44
48
  timeout?: number;
45
49
  }): Promise<void>;
46
- export declare const PAINT_CONTRACT_NOTE: string;
47
- /**
48
- * Whether every open display's paint state was actually measurable.
49
- *
50
- * The per-display paint attributes are not universal. Measured on 2026-08-07:
51
- * jbrowse.org/code/jb2/main publishes `data-view-phase`, `data-display-phase`
52
- * and `data-display-drawn`; jbrowse.org/code/jb2/latest — the released build,
53
- * which is what every genomes.jbrowse.org link and every docs figure link opens
54
- * — publishes none of them and exposes only the loading overlay. Against that
55
- * instance the display-level waits are unfalsifiable rather than satisfied, so a
56
- * caller has to be told which of the two it got instead of reading "0 displays
57
- * pending" as good news.
58
- *
59
- * The absence of the attribute is NOT on its own the answer, which is the trap
60
- * here: a page with no tracks open — an import form, a menu shot, a bare view —
61
- * publishes none of it either, and reporting that as "this build cannot measure
62
- * paint" is both wrong and alarming. So this asks the question only when there
63
- * is something to measure, and answers true when there is not.
64
- *
65
- * Call it AFTER the session gate, so "no tracks open" means the page genuinely
66
- * has none rather than not having got there yet.
67
- */
68
- export declare function hasPaintContract(page: Page): Promise<boolean>;
69
- /**
70
- * Which readiness attributes this build publishes at all.
71
- *
72
- * `hasPaintContract` answers the same question for one attribute and folds in
73
- * "there was nothing to measure"; this is the raw read of all three, so the
74
- * chain can adapt to the build BEFORE it starts waiting rather than reporting
75
- * afterwards what it could not see.
76
- *
77
- * Every wait keyed on one of these is NEGATIVE — it passes when the selector is
78
- * absent — so on a build that publishes none of them all three are satisfied by
79
- * a page that has not begun to draw. Read this after the session gate and treat
80
- * `false` as "this signal is unavailable", never as "this signal says done".
81
- */
82
- export interface Instrumentation {
83
- viewPhase: boolean;
84
- displayPhase: boolean;
85
- displayDrawn: boolean;
86
- }
87
- /** Serialized into the page; exported so a test can call the real function. */
88
- export declare function readInstrumentationInPage(): Instrumentation;
89
- export declare function readInstrumentation(page: Page): Promise<Instrumentation>;
90
50
  /**
91
51
  * Displays that were still reporting unpainted at the moment of the call.
92
52
  *
93
53
  * Distinct from `unsettled`, which says a wait ran out of time. This says what
94
- * the page looked like when the shutter fired, and the two do not imply each
95
- * other: a display can go back to pending after its stage passed. An empty
96
- * result means nothing at all on a build with no paint contract — check
97
- * `paintContract` before reading it as good news.
54
+ * the page looked like when the shutter fired.
98
55
  */
99
56
  export declare function pendingDisplays(page: Page): Promise<string[]>;
100
57
  /** One unpainted display and what it says about itself. */
@@ -104,10 +61,9 @@ export interface PendingDisplay {
104
61
  /** which instance, where the display publishes `data-display-id` */
105
62
  id?: string;
106
63
  /**
107
- * Its own phase, or undefined on a build too old to publish one. `loading` is
108
- * a display still fetching; `ready` is the one that earns this census, since
109
- * a display that says it has finished while reporting no paint is a bug in
110
- * the display rather than a slow page.
64
+ * Its own phase. `loading` is a display still fetching; `ready` is the one
65
+ * that earns this census, since a display that says it has finished while
66
+ * reporting no paint is a bug in the display rather than a slow page.
111
67
  */
112
68
  phase?: string;
113
69
  }
@@ -123,14 +79,12 @@ export interface PendingDisplay {
123
79
  * `ready` it says it is done and reports no paint. That is the display's
124
80
  * bug, not the wait's, and it is the one a longer timeout will
125
81
  * never fix.
126
- * absent the element publishes no phase: a build older than the
127
- * attribute, or a surface that never grew one.
128
82
  *
129
83
  * Serialized into the page, so a test can call the real function. Read fresh
130
- * from the DOM at report time rather than from handles the waits held. A handle to an element that has since re-rendered throws
131
- * `Node is detached from document`, which is how the previous attempt at this
132
- * turned four diagnosable timeouts into nine opaque puppeteer errors (reverted
133
- * in 28c6ee6d90).
84
+ * from the DOM at report time rather than from handles the waits held. A handle
85
+ * to an element that has since re-rendered throws `Node is detached from
86
+ * document`, which is how the previous attempt at this turned four diagnosable
87
+ * timeouts into nine opaque puppeteer errors (reverted in 28c6ee6d90).
134
88
  */
135
89
  export declare function pendingDisplayStatesInPage(): PendingDisplay[];
136
90
  export declare function pendingDisplayStates(page: Page): Promise<PendingDisplay[]>;