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

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.
@@ -1,73 +1,46 @@
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
1
  /**
8
2
  * The census the app publishes beside its phase: `AppReadyMarker` renders
9
3
  * `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.
4
+ * (JSON string arrays of what is open).
5
+ *
6
+ * One element answers the whole gate. Each view declares what it holds and
7
+ * `openViews`/`openTracks` reduce over those declarations (ADR-103), so nothing
8
+ * here has to know that a synteny view keeps its tracks on levels and its rows
9
+ * on sub-views — the duck-typed walk of `window.JBrowseSession` that used to
10
+ * live here, and drifted against the copy in `isPageBusyInPage`, is gone with
11
+ * the builds that needed it.
12
+ *
15
13
  * `scripts/readinessContract.test.ts` pins the attribute names against the
16
14
  * marker, since neither package may import the other.
17
15
  */
18
16
  export const APP_CENSUS = '[data-app-tracks]';
19
- /** What the page currently has open, or undefined if there is no session yet. */
17
+ /** What the page currently has open, or undefined if there is no census yet. */
20
18
  export function readSessionSummary(page) {
21
19
  return page.evaluate(readSessionSummaryInPage);
22
20
  }
23
- // Serialized into the page, so it can only call what it declares — hence the
24
- // inlined copy of readViews rather than a shared import.
25
- //
26
- // Exported for its test: it reads `globalThis.JBrowseSession` and nothing else,
27
- // so calling it in node against a stubbed global exercises the very function
28
- // puppeteer serializes, rather than a copy of it that can drift.
21
+ // Serialized into the page, so it can only call what it declares. Exported for
22
+ // its test: it reads the document and nothing else, so calling it in node
23
+ // against a staged DOM exercises the very function puppeteer serializes, rather
24
+ // than a copy of it that can drift.
29
25
  export function readSessionSummaryInPage() {
30
- // census first: one element, no walk (see APP_CENSUS)
31
26
  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
- }
44
- const session = globalThis.JBrowseSession;
45
- const views = session?.views;
46
- if (!views) {
27
+ if (!marker) {
47
28
  return undefined;
48
29
  }
49
- // Inlined rather than shared with the gate below for the same reason as the
50
- // readViews copy: both are serialized into the page.
51
- function collect(v) {
52
- return [
53
- ...(v.tracks ?? []),
54
- ...(v.trackContainers ?? v.levels ?? []).flatMap(l => l.tracks ?? []),
55
- ...(v.views ?? []).flatMap(collect),
56
- ];
30
+ try {
31
+ return {
32
+ views: Number(marker.dataset.appViews) || 0,
33
+ assemblies: JSON.parse(marker.dataset.appAssemblies ?? '[]'),
34
+ trackIds: JSON.parse(marker.dataset.appTracks ?? '[]'),
35
+ };
36
+ }
37
+ catch {
38
+ return undefined;
57
39
  }
58
- return {
59
- views: views.length,
60
- assemblies: [
61
- ...new Set(views.flatMap(function asm(v) {
62
- return [...(v.assemblyNames ?? []), ...(v.views ?? []).flatMap(asm)];
63
- })),
64
- ],
65
- trackIds: views.flatMap(v => collect(v).map(t => t.configuration?.trackId ?? '(unnamed)')),
66
- };
67
40
  }
68
41
  /**
69
- * Wait until the session exists, every view reports itself initialized, and the
70
- * assembly and tracks that were asked for are actually open.
42
+ * Wait until the census exists and the assembly and tracks that were asked for
43
+ * are actually open.
71
44
  *
72
45
  * Throws on timeout rather than proceeding. A config URL that 404s, a trackId
73
46
  * that does not exist in the config, and an assembly name that does not match
@@ -79,53 +52,21 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
79
52
  try {
80
53
  // #region session-gate
81
54
  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
55
  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.
100
- const session = globalThis.JBrowseSession;
101
- const views = session?.views;
102
- if (!views?.length) {
56
+ if (!marker) {
103
57
  return false;
104
58
  }
105
- // `initialized` is an LGV getter; a view type without one is mounted
106
- // content the moment it exists, so absent counts as initialized and
107
- // only an explicit false is pending.
108
- if (views.some(v => v.initialized === false)) {
109
- return false;
59
+ try {
60
+ const openViews = Number(marker.dataset.appViews) || 0;
61
+ const assemblies = JSON.parse(marker.dataset.appAssemblies ?? '[]');
62
+ const openTracks = JSON.parse(marker.dataset.appTracks ?? '[]');
63
+ return (openViews > 0 &&
64
+ (wantAssembly === null || assemblies.includes(wantAssembly)) &&
65
+ wantTracks.every(id => openTracks.includes(id)));
110
66
  }
111
- // A container view (synteny, dotplot) keeps its assemblies on the rows
112
- // and its tracks on the levels, so both walks descend into sub-views
113
- // and levels rather than reading the top view only.
114
- const asmOf = (v) => [
115
- ...(v.assemblyNames ?? []),
116
- ...(v.views ?? []).flatMap(asmOf),
117
- ];
118
- if (wantAssembly !== null &&
119
- !views.some(v => asmOf(v).includes(wantAssembly))) {
67
+ catch {
120
68
  return false;
121
69
  }
122
- const tracksOf = (v) => [
123
- ...(v.tracks ?? []),
124
- ...(v.trackContainers ?? v.levels ?? []).flatMap(l => l.tracks ?? []),
125
- ...(v.views ?? []).flatMap(tracksOf),
126
- ];
127
- const open = new Set(views.flatMap(v => tracksOf(v).map(t => t.configuration?.trackId)));
128
- return wantTracks.every(id => open.has(id));
129
70
  }, { timeout, polling: 250 }, assembly ?? null, trackIds);
130
71
  // #endregion session-gate
131
72
  }
@@ -136,7 +77,9 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
136
77
  const summary = await readSessionSummary(page).catch(() => undefined);
137
78
  const found = summary
138
79
  ? `${summary.views} view(s), assemblies [${summary.assemblies.join(', ')}], tracks [${summary.trackIds.join(', ')}]`
139
- : 'no session on the page at all (is this a jbrowse-web instance?)';
80
+ : 'no census on the page at all — either this is not a JBrowse app, or ' +
81
+ 'it predates the readiness marker, in which case point --instance at a ' +
82
+ 'build that publishes one';
140
83
  const missing = summary
141
84
  ? trackIds.filter(id => !summary.trackIds.includes(id))
142
85
  : trackIds;
@@ -147,61 +90,16 @@ export async function waitForSession(page, { assembly, trackIds = [], timeout =
147
90
  .filter(Boolean)
148
91
  .join(' and ');
149
92
  throw new Error(`the session never reached the requested state after ${timeout}ms. ` +
150
- `Wanted ${wanted || 'an initialized view'}; found ${found}. ` +
93
+ `Wanted ${wanted || 'an open view'}; found ${found}. ` +
151
94
  'A config URL that 404s, a trackId the config does not define, or an ' +
152
95
  'assembly name that does not match the config all look like this.');
153
96
  }
154
97
  }
155
- export const PAINT_CONTRACT_NOTE = 'this JBrowse build publishes no data-display-drawn attributes, so "every ' +
156
- 'display has painted" cannot be checked — only that nothing is still ' +
157
- 'loading. Raise --settle if the image looks half-drawn.';
158
- /**
159
- * Whether every open display's paint state was actually measurable.
160
- *
161
- * The per-display paint attributes are not universal. Measured on 2026-08-07:
162
- * jbrowse.org/code/jb2/main publishes `data-view-phase`, `data-display-phase`
163
- * and `data-display-drawn`; jbrowse.org/code/jb2/latest — the released build,
164
- * which is what every genomes.jbrowse.org link and every docs figure link opens
165
- * — publishes none of them and exposes only the loading overlay. Against that
166
- * instance the display-level waits are unfalsifiable rather than satisfied, so a
167
- * caller has to be told which of the two it got instead of reading "0 displays
168
- * pending" as good news.
169
- *
170
- * The absence of the attribute is NOT on its own the answer, which is the trap
171
- * here: a page with no tracks open — an import form, a menu shot, a bare view —
172
- * publishes none of it either, and reporting that as "this build cannot measure
173
- * paint" is both wrong and alarming. So this asks the question only when there
174
- * is something to measure, and answers true when there is not.
175
- *
176
- * Call it AFTER the session gate, so "no tracks open" means the page genuinely
177
- * has none rather than not having got there yet.
178
- */
179
- export async function hasPaintContract(page) {
180
- const summary = await readSessionSummary(page);
181
- if (summary && summary.trackIds.length === 0) {
182
- return true;
183
- }
184
- return page.evaluate(() => document.querySelector('[data-display-drawn]') !== null);
185
- }
186
- /** Serialized into the page; exported so a test can call the real function. */
187
- export function readInstrumentationInPage() {
188
- return {
189
- viewPhase: document.querySelector('[data-view-phase]') !== null,
190
- displayPhase: document.querySelector('[data-display-phase]') !== null,
191
- displayDrawn: document.querySelector('[data-display-drawn]') !== null,
192
- };
193
- }
194
- export function readInstrumentation(page) {
195
- return page.evaluate(readInstrumentationInPage);
196
- }
197
98
  /**
198
99
  * Displays that were still reporting unpainted at the moment of the call.
199
100
  *
200
101
  * Distinct from `unsettled`, which says a wait ran out of time. This says what
201
- * the page looked like when the shutter fired, and the two do not imply each
202
- * other: a display can go back to pending after its stage passed. An empty
203
- * result means nothing at all on a build with no paint contract — check
204
- * `paintContract` before reading it as good news.
102
+ * the page looked like when the shutter fired.
205
103
  */
206
104
  export async function pendingDisplays(page) {
207
105
  return (await pendingDisplayStates(page)).map(d => d.name);
@@ -218,14 +116,12 @@ export async function pendingDisplays(page) {
218
116
  * `ready` it says it is done and reports no paint. That is the display's
219
117
  * bug, not the wait's, and it is the one a longer timeout will
220
118
  * never fix.
221
- * absent the element publishes no phase: a build older than the
222
- * attribute, or a surface that never grew one.
223
119
  *
224
120
  * Serialized into the page, so a test can call the real function. Read fresh
225
- * from the DOM at report time rather than from handles the waits held. A handle to an element that has since re-rendered throws
226
- * `Node is detached from document`, which is how the previous attempt at this
227
- * turned four diagnosable timeouts into nine opaque puppeteer errors (reverted
228
- * in 28c6ee6d90).
121
+ * from the DOM at report time rather than from handles the waits held. A handle
122
+ * to an element that has since re-rendered throws `Node is detached from
123
+ * document`, which is how the previous attempt at this turned four diagnosable
124
+ * timeouts into nine opaque puppeteer errors (reverted in 28c6ee6d90).
229
125
  */
230
126
  export function pendingDisplayStatesInPage() {
231
127
  return [
package/esm/waits.d.ts CHANGED
@@ -115,11 +115,15 @@ export declare const BUSY_SELECTOR: string;
115
115
  * Is the app doing anything right now?
116
116
  *
117
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
122
- * attributes — so neither moves when the UI is restyled.
118
+ * `BUSY_SELECTOR` as an argument rather than importing it. Every signal is a
119
+ * data attribute a component sets deliberately, so restyling or rewording the
120
+ * UI cannot move the answer.
121
+ *
122
+ * It used to also walk `window.JBrowseSession` for each display's own status
123
+ * message — the only PER-DISPLAY signal a build with no readiness attributes
124
+ * had. That walk was a duck-typed copy of the session gate's, drifted onto a
125
+ * different container spelling (ADR-103), and it went with the builds that
126
+ * needed it: `data-display-phase="loading"` covers the same fetch directly.
123
127
  *
124
128
  * Exported so a test can call the real function rather than a copy of it.
125
129
  */
@@ -127,49 +131,25 @@ export declare function isPageBusyInPage(busySelector: string): boolean;
127
131
  /**
128
132
  * Wait until the app has been idle for an unbroken stretch.
129
133
  *
130
- * The gate for a build that publishes no readiness attributes, where every
131
- * other wait in this module is an assertion about an absent selector and so
132
- * cannot fail. Absence answers "is it working NOW"; a capture needs "has it
133
- * finished", and the two differ in both directions:
134
- *
135
- * BEFORE the work starts. Measured on jbrowse.org/code/jb2/latest with two
136
- * remote tracks: the session reports both tracks open at ~2.5s and the
137
- * loading overlay does not go up until ~3.5s. Every absence-based gate passes
138
- * during that second, over an app that has drawn nothing.
134
+ * Idle answers "is it working NOW"; a page is finished only if it stays that
135
+ * way, because a track that ends one fetch and starts the next is momentarily
136
+ * idle and a single-sample read takes that gap for the end. Requiring the idle
137
+ * to HOLD closes it.
139
138
  *
140
- * BETWEEN two pieces of work. A track that finishes one fetch and starts the
141
- * next is momentarily idle, and a single-sample gate takes it.
139
+ * NOT a readiness gate on its own — idle is an absence, equally true of an app
140
+ * that has not started. `waitForAppSettled` is the gate; this is the tool for
141
+ * "nothing is in flight right now", which is what a probe or a benchmark wants
142
+ * between two measured actions.
142
143
  *
143
- * Requiring the idle to HOLD closes both without needing to know which signals
144
- * a given build has. Polled from node rather than in-page: chrome throttles
145
- * in-page timers and rAF once the tab is not visible, which is the state a
146
- * headless capture sits in.
144
+ * Polled from node rather than in-page: chrome throttles in-page timers and rAF
145
+ * once the tab is not visible, which is the state a headless capture sits in.
147
146
  *
148
- * Returns false on timeout rather than throwing, like its neighbours — the
149
- * caller decides whether a page that never went quiet is a failure or a slow
150
- * page worth capturing anyway.
147
+ * Returns false on timeout rather than throwing, like its neighbours.
151
148
  */
152
- export declare function waitForQuietPeriod(page: Page, { quietMs, timeout, pollMs, busyWindowMs, }?: {
149
+ export declare function waitForQuietPeriod(page: Page, { quietMs, timeout, pollMs, }?: {
153
150
  quietMs?: number;
154
151
  timeout?: number;
155
152
  pollMs?: number;
156
- /**
157
- * Wait for the app to be seen BUSY before any idle counts, giving up on
158
- * that after this long. Zero (the default) accepts idle immediately.
159
- *
160
- * This is what turns the wait positive. Idle is still an absence, so an app
161
- * that has not begun looks exactly like one that has finished — measured on
162
- * jbrowse.org/code/jb2/latest, where a chain starting at the session gate
163
- * saw an idle page at 1.6s and the tracks did not draw until past 10s.
164
- * Seeing the transition INTO work and back out of it is an observation of
165
- * the work itself.
166
- *
167
- * The window is bounded because a page with nothing to fetch never goes
168
- * busy at all, and hanging on that would be worse than the race it closes.
169
- * Size it above the gap between the session gate passing and the first
170
- * indicator appearing, measured at ~1s on that instance.
171
- */
172
- busyWindowMs?: number;
173
153
  }): Promise<boolean>;
174
154
  /**
175
155
  * Wait for the FIRST frame the app says it has finished.
@@ -180,8 +160,7 @@ export declare function waitForQuietPeriod(page: Page, { quietMs, timeout, pollM
180
160
  * debounced next one starting. `waitForAppSettled` requires it to HOLD, and is
181
161
  * what both the chain here and `jb.waitReady` use.
182
162
  *
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.
163
+ * Returns false if it never appears within the timeout.
185
164
  */
186
165
  export declare function waitForAppReady(page: Page, { timeout }?: {
187
166
  timeout?: number;
@@ -216,9 +195,8 @@ export declare function hasAppReadyMarker(page: Page): Promise<boolean>;
216
195
  * `waitForQuietPeriod` is: chrome throttles in-page timers and rAF once the tab
217
196
  * is not visible, which is exactly the state a headless capture sits in.
218
197
  *
219
- * Falls back to the quiet period on a build too old for the marker, rather than
220
- * passing instantly. A no-op that reports success is how a spec that dropped its
221
- * sleep for this ends up capturing the frame the sleep was there to avoid.
198
+ * Throws on a build too old for the marker rather than falling back — see the
199
+ * body.
222
200
  */
223
201
  export declare function waitForAppSettled(page: Page, { timeout, holdMs, pollMs, }?: {
224
202
  timeout?: number;
package/esm/waits.js CHANGED
@@ -55,7 +55,7 @@ const APP_SETTLED_HOLD_MS = 1000;
55
55
  * finished, and there is no content behind it to fall through to.
56
56
  */
57
57
  export async function waitForLoadingComplete(page, { timeout = 30000, waitForDownloads = false, } = {}) {
58
- await page.waitForFunction((selector) => document.querySelectorAll(selector).length === 0, { timeout, polling: 'mutation' }, LOADING_OVERLAY);
58
+ await page.waitForFunction((selector) => document.querySelector(selector) === null, { timeout, polling: 'mutation' }, LOADING_OVERLAY);
59
59
  // `quietMs: 0` because this is the "nothing is in flight right now" question;
60
60
  // the one that needs the idle to hold is waitForQuietPeriod's own caller.
61
61
  return waitForDownloads
@@ -257,7 +257,7 @@ export function waitForViewPhases(page, timeoutMs) {
257
257
  * `data-view-phase` a view still resolving its assembly, newer builds
258
258
  */
259
259
  export const BUSY_SELECTOR = [
260
- '[data-testid="loading-overlay"]',
260
+ LOADING_OVERLAY,
261
261
  '[data-busy="true"]',
262
262
  '[data-display-phase="loading"]',
263
263
  '[data-view-phase="loading"]',
@@ -266,60 +266,42 @@ export const BUSY_SELECTOR = [
266
266
  * Is the app doing anything right now?
267
267
  *
268
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
273
- * attributes — so neither moves when the UI is restyled.
269
+ * `BUSY_SELECTOR` as an argument rather than importing it. Every signal is a
270
+ * data attribute a component sets deliberately, so restyling or rewording the
271
+ * UI cannot move the answer.
272
+ *
273
+ * It used to also walk `window.JBrowseSession` for each display's own status
274
+ * message — the only PER-DISPLAY signal a build with no readiness attributes
275
+ * had. That walk was a duck-typed copy of the session gate's, drifted onto a
276
+ * different container spelling (ADR-103), and it went with the builds that
277
+ * needed it: `data-display-phase="loading"` covers the same fetch directly.
274
278
  *
275
279
  * Exported so a test can call the real function rather than a copy of it.
276
280
  */
277
281
  export function isPageBusyInPage(busySelector) {
278
- if (document.querySelector(busySelector)) {
279
- return true;
280
- }
281
- const session = globalThis
282
- .JBrowseSession;
283
- const tracksOf = (c) => (c.tracks ?? []).flatMap(t => t.displays ?? []);
284
- const displaysOf = (v) => [
285
- ...tracksOf(v),
286
- ...(v.trackContainers ?? v.levels ?? []).flatMap(tracksOf),
287
- ...(v.views ?? []).flatMap(displaysOf),
288
- ];
289
- return (session?.views ?? [])
290
- .flatMap(displaysOf)
291
- .some(d => (d.message ?? d.statusMessage ?? '').trim() !== '');
282
+ return document.querySelector(busySelector) !== null;
292
283
  }
293
284
  /**
294
285
  * Wait until the app has been idle for an unbroken stretch.
295
286
  *
296
- * The gate for a build that publishes no readiness attributes, where every
297
- * other wait in this module is an assertion about an absent selector and so
298
- * cannot fail. Absence answers "is it working NOW"; a capture needs "has it
299
- * finished", and the two differ in both directions:
300
- *
301
- * BEFORE the work starts. Measured on jbrowse.org/code/jb2/latest with two
302
- * remote tracks: the session reports both tracks open at ~2.5s and the
303
- * loading overlay does not go up until ~3.5s. Every absence-based gate passes
304
- * during that second, over an app that has drawn nothing.
287
+ * Idle answers "is it working NOW"; a page is finished only if it stays that
288
+ * way, because a track that ends one fetch and starts the next is momentarily
289
+ * idle and a single-sample read takes that gap for the end. Requiring the idle
290
+ * to HOLD closes it.
305
291
  *
306
- * BETWEEN two pieces of work. A track that finishes one fetch and starts the
307
- * next is momentarily idle, and a single-sample gate takes it.
292
+ * NOT a readiness gate on its own — idle is an absence, equally true of an app
293
+ * that has not started. `waitForAppSettled` is the gate; this is the tool for
294
+ * "nothing is in flight right now", which is what a probe or a benchmark wants
295
+ * between two measured actions.
308
296
  *
309
- * Requiring the idle to HOLD closes both without needing to know which signals
310
- * a given build has. Polled from node rather than in-page: chrome throttles
311
- * in-page timers and rAF once the tab is not visible, which is the state a
312
- * headless capture sits in.
297
+ * Polled from node rather than in-page: chrome throttles in-page timers and rAF
298
+ * once the tab is not visible, which is the state a headless capture sits in.
313
299
  *
314
- * Returns false on timeout rather than throwing, like its neighbours — the
315
- * caller decides whether a page that never went quiet is a failure or a slow
316
- * page worth capturing anyway.
300
+ * Returns false on timeout rather than throwing, like its neighbours.
317
301
  */
318
- export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000, pollMs = 250, busyWindowMs = 0, } = {}) {
319
- const start = Date.now();
320
- const deadline = start + timeout;
302
+ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000, pollMs = 250, } = {}) {
303
+ const deadline = Date.now() + timeout;
321
304
  let quietSince;
322
- let seenBusy = busyWindowMs === 0;
323
305
  while (Date.now() < deadline) {
324
306
  // A page that navigates or closes under us fails the evaluate; treat that
325
307
  // as busy and let the deadline decide, rather than reporting quiet.
@@ -328,22 +310,12 @@ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000
328
310
  .catch(() => true);
329
311
  const now = Date.now();
330
312
  if (busy) {
331
- seenBusy = true;
332
313
  quietSince = undefined;
333
314
  }
334
315
  else {
335
- if (!seenBusy && now - start >= busyWindowMs) {
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.
339
- seenBusy = true;
340
- quietSince = start;
341
- }
342
- if (seenBusy) {
343
- quietSince ??= now;
344
- if (now - quietSince >= quietMs) {
345
- return true;
346
- }
316
+ quietSince ??= now;
317
+ if (now - quietSince >= quietMs) {
318
+ return true;
347
319
  }
348
320
  }
349
321
  await delay(pollMs);
@@ -359,8 +331,7 @@ export async function waitForQuietPeriod(page, { quietMs = 1500, timeout = 30000
359
331
  * debounced next one starting. `waitForAppSettled` requires it to HOLD, and is
360
332
  * what both the chain here and `jb.waitReady` use.
361
333
  *
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.
334
+ * Returns false if it never appears within the timeout.
364
335
  */
365
336
  export function waitForAppReady(page, { timeout = 30000 } = {}) {
366
337
  return settled(page.waitForSelector(APP_READY, { timeout, visible: false }));
@@ -397,13 +368,19 @@ export function hasAppReadyMarker(page) {
397
368
  * `waitForQuietPeriod` is: chrome throttles in-page timers and rAF once the tab
398
369
  * is not visible, which is exactly the state a headless capture sits in.
399
370
  *
400
- * Falls back to the quiet period on a build too old for the marker, rather than
401
- * passing instantly. A no-op that reports success is how a spec that dropped its
402
- * sleep for this ends up capturing the frame the sleep was there to avoid.
371
+ * Throws on a build too old for the marker rather than falling back — see the
372
+ * body.
403
373
  */
404
374
  export async function waitForAppSettled(page, { timeout = 30000, holdMs = APP_SETTLED_HOLD_MS, pollMs = 250, } = {}) {
405
375
  if (!(await hasAppReadyMarker(page))) {
406
- return waitForQuietPeriod(page, { quietMs: holdMs, timeout, pollMs });
376
+ // Throws rather than falling back to the quiet period. The fallback was a
377
+ // no-op that reported success on a build with no attributes at all, which
378
+ // is how a spec that dropped its fixed sleep for this ended up capturing
379
+ // the frame the sleep was there to avoid. A build without the marker needs
380
+ // a newer build, not a wait that cannot fail.
381
+ throw new Error('this page publishes no [data-app-phase], so there is nothing positive ' +
382
+ 'to wait for — every other readiness attribute is an absence an app ' +
383
+ 'that has not started also satisfies.');
407
384
  }
408
385
  const deadline = Date.now() + timeout;
409
386
  let readySince;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/capture",
3
- "version": "5.0.0-beta.4",
3
+ "version": "5.0.0-beta.8",
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.10.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@jbrowse/core": "^5.0.0-beta.4"
37
+ "@jbrowse/core": "^5.0.0-beta.8"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"