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

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/catalog.js ADDED
@@ -0,0 +1,103 @@
1
+ import { hubUrl } from './hub.js';
2
+ import { PUBLIC_INSTANCE } from './url.js';
3
+ const CATALOG_FETCH_TIMEOUT_MS = 30000;
4
+ const trackName = (track) => typeof track.name === 'string' ? track.name : '';
5
+ const assembliesOf = (catalog) => catalog.assemblies ?? (catalog.assembly ? [catalog.assembly] : []);
6
+ /**
7
+ * The canonical name of the assembly `input` names by name or alias, ignoring
8
+ * case. The canonical name is the one the app's census publishes, so the
9
+ * session gate compares against it rather than against an alias.
10
+ */
11
+ export function resolveAssemblyName(catalog, input) {
12
+ const assemblies = assembliesOf(catalog);
13
+ const target = input.toLowerCase();
14
+ const found = assemblies.find(a => [a.name, ...(a.aliases ?? [])].some(n => n.toLowerCase() === target));
15
+ if (!found) {
16
+ const names = assemblies.map(a => a.name).join(', ');
17
+ throw new Error(`assembly "${input}" is not in the config, which has ${names || 'none'}`);
18
+ }
19
+ return found.name;
20
+ }
21
+ /**
22
+ * The trackId `input` names, resolved the way `jb2export --track` resolves one:
23
+ * an exact trackId, then the id with the assembly prefix a hosted config puts
24
+ * on every track (`clinvarMain` for `hg38-clinvarMain`), then a single
25
+ * case-insensitive match on the id, the unprefixed id or the display name. A
26
+ * miss names the tracks whose id or name contains what was typed.
27
+ */
28
+ export function resolveTrackId(tracks, input, assemblyName) {
29
+ const ids = new Set(tracks.map(t => t.trackId));
30
+ const prefixed = `${assemblyName}-${input}`;
31
+ if (ids.has(input)) {
32
+ return input;
33
+ }
34
+ if (ids.has(prefixed)) {
35
+ return prefixed;
36
+ }
37
+ const target = input.toLowerCase();
38
+ const prefix = `${assemblyName}-`.toLowerCase();
39
+ const loose = tracks.filter(t => {
40
+ const id = t.trackId.toLowerCase();
41
+ return (id === target ||
42
+ (id.startsWith(prefix) && id.slice(prefix.length) === target) ||
43
+ trackName(t).toLowerCase() === target);
44
+ });
45
+ if (loose.length === 1) {
46
+ return loose[0].trackId;
47
+ }
48
+ if (loose.length > 1) {
49
+ throw new Error(`--track "${input}" is ambiguous; matches: ${loose.map(t => t.trackId).join(', ')}`);
50
+ }
51
+ const suggestions = tracks
52
+ .filter(t => `${t.trackId} ${trackName(t)}`.toLowerCase().includes(target))
53
+ .slice(0, 8)
54
+ .map(t => t.trackId);
55
+ const hint = suggestions.length
56
+ ? `. Did you mean: ${suggestions.join(', ')}?`
57
+ : '';
58
+ throw new Error(`--track "${input}" is not in the config${hint}`);
59
+ }
60
+ async function fetchCatalog(url) {
61
+ try {
62
+ const res = await fetch(url, {
63
+ signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS),
64
+ });
65
+ return res.ok ? (await res.json()) : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /**
72
+ * The same options with the assembly and tracks checked against the config
73
+ * and spelled the way the config spells them, before any browser launches: a
74
+ * typo fails here with suggestions rather than as a session-gate timeout.
75
+ *
76
+ * A config this cannot fetch or parse passes through unchanged, and the app
77
+ * reports it. A spec or saved session passes through too, since it may declare
78
+ * tracks of its own that no config lists.
79
+ */
80
+ export async function resolveAgainstConfig(options) {
81
+ const { hub, config, assembly, tracks, spec, session, instance = PUBLIC_INSTANCE, } = options;
82
+ const wanted = assembly ?? hub;
83
+ const url = config
84
+ ? new URL(config, instance).href
85
+ : hub
86
+ ? hubUrl(hub)
87
+ : undefined;
88
+ if (spec || session || !url || (!wanted && !tracks?.length)) {
89
+ return options;
90
+ }
91
+ const catalog = await fetchCatalog(url);
92
+ if (!catalog) {
93
+ return options;
94
+ }
95
+ const assemblyName = wanted
96
+ ? resolveAssemblyName(catalog, wanted)
97
+ : assembliesOf(catalog)[0]?.name;
98
+ return {
99
+ ...options,
100
+ assembly: wanted ? assemblyName : undefined,
101
+ tracks: tracks?.map(t => resolveTrackId(catalog.tracks ?? [], t, assemblyName ?? '')),
102
+ };
103
+ }
package/esm/hub.d.ts CHANGED
@@ -14,6 +14,16 @@ interface HubConfig {
14
14
  * before naming them in a session.
15
15
  */
16
16
  export declare function fetchHubConfig(hub: string): Promise<HubConfig>;
17
+ interface HostedAssembly {
18
+ name: string;
19
+ organism?: string;
20
+ description?: string;
21
+ }
22
+ /**
23
+ * The UCSC assemblies genomes.jbrowse.org hosts, by name. GenArk accessions
24
+ * are hosted too, thousands of them, and are not in this list.
25
+ */
26
+ export declare function listHubAssemblies(): Promise<HostedAssembly[]>;
17
27
  /**
18
28
  * The trackIds a hosted assembly publishes, optionally narrowed to those whose
19
29
  * id or display name contains `filter`. Case-insensitive, because a hub carries
package/esm/hub.js CHANGED
@@ -16,31 +16,46 @@ export function hubUrl(hub) {
16
16
  ? `${HUB_HOST}/hubs/genark/${genark[1]}/${genark[2]}/${genark[3]}/${genark[4]}/${hub}/config.json`
17
17
  : `${HUB_HOST}/ucsc/${hub}/config.json`;
18
18
  }
19
- /**
20
- * Fetch a hosted assembly config from genomes.jbrowse.org by UCSC database name
21
- * or GenArk accession. Use it to discover which trackIds an assembly publishes
22
- * before naming them in a session.
23
- */
24
- export async function fetchHubConfig(hub) {
25
- const url = hubUrl(hub);
26
- // Without the signal a stalled connection hangs `jb2capture list` forever:
27
- // fetch has no timeout of its own, and this is the one request the tool makes
28
- // outside puppeteer's budget.
19
+ const BROWSE_HINT = 'See https://genomes.jbrowse.org for the available assemblies.';
20
+ // Bounded: fetch has no timeout of its own, and these are the requests the tool
21
+ // makes outside puppeteer's budget, so without it a stalled connection hangs
22
+ // `jb2capture list` forever.
23
+ async function fetchHosted(url, what) {
29
24
  const res = await fetch(url, {
30
25
  signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS),
31
26
  }).catch((error) => {
32
27
  // The budget is only part of the diagnosis when it is what fired; naming it
33
28
  // on a DNS failure reads as though the request had been given 30s to resolve.
34
29
  const timedOut = error instanceof Error && error.name === 'TimeoutError';
35
- throw new Error(`hub "${hub}" could not be fetched from ${url} ` +
36
- `(${error instanceof Error ? error.message : error}${timedOut ? `, after ${HUB_FETCH_TIMEOUT_MS}ms` : ''}). ` +
37
- 'See https://genomes.jbrowse.org for the available assemblies.');
30
+ throw new Error(`${what} could not be fetched from ${url} ` +
31
+ `(${error instanceof Error ? error.message : error}${timedOut ? `, after ${HUB_FETCH_TIMEOUT_MS}ms` : ''}). ${BROWSE_HINT}`);
38
32
  });
39
33
  if (!res.ok) {
40
- throw new Error(`hub "${hub}" not found (HTTP ${res.status} from ${url}). ` +
41
- 'See https://genomes.jbrowse.org for the available assemblies.');
34
+ throw new Error(`${what} not found (HTTP ${res.status} from ${url}). ${BROWSE_HINT}`);
42
35
  }
43
- return (await res.json());
36
+ return res.json();
37
+ }
38
+ /**
39
+ * Fetch a hosted assembly config from genomes.jbrowse.org by UCSC database name
40
+ * or GenArk accession. Use it to discover which trackIds an assembly publishes
41
+ * before naming them in a session.
42
+ */
43
+ export async function fetchHubConfig(hub) {
44
+ return (await fetchHosted(hubUrl(hub), `hub "${hub}"`));
45
+ }
46
+ /**
47
+ * The UCSC assemblies genomes.jbrowse.org hosts, by name. GenArk accessions
48
+ * are hosted too, thousands of them, and are not in this list.
49
+ */
50
+ export async function listHubAssemblies() {
51
+ const { ucscGenomes = {} } = (await fetchHosted(`${HUB_HOST}/ucsc/list.json`, 'the hosted assembly list'));
52
+ return Object.entries(ucscGenomes)
53
+ .map(([name, { organism, description }]) => ({
54
+ name,
55
+ organism,
56
+ description,
57
+ }))
58
+ .sort((a, b) => a.name.localeCompare(b.name));
44
59
  }
45
60
  /**
46
61
  * The trackIds a hosted assembly publishes, optionally narrowed to those whose
@@ -0,0 +1 @@
1
+ export declare function assertImagePath(out: string): void;
@@ -0,0 +1,8 @@
1
+ // The types puppeteer infers from a path; it writes a PNG under any other name.
2
+ const IMAGE_EXTENSION = /\.(png|jpe?g|webp)$/i;
3
+ export function assertImagePath(out) {
4
+ if (!IMAGE_EXTENSION.test(out)) {
5
+ throw new Error(`--out needs a .png, .jpg, .jpeg or .webp path, got "${out}" ` +
6
+ '(for SVG, @jbrowse/img renders one without a browser)');
7
+ }
8
+ }
package/esm/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  export { captureJBrowse, openJBrowse } from './capture.ts';
2
- export { waitForJBrowseReady } from './ready.ts';
3
- export { APP_CENSUS, describePendingDisplays, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readSessionSummary, waitForSession, } from './sessionGate.ts';
4
- export { PUBLIC_INSTANCE, jbrowseUrl } from './url.ts';
5
- export { fetchHubConfig, hubUrl, listHubTracks } from './hub.ts';
6
- export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.ts';
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';
8
- export { BASE_CHROME_ARGS, SANDBOX_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.ts';
2
+ export { delay } from './poll.ts';
3
+ export { waitForFrame, waitForJBrowseReady } from './ready.ts';
4
+ export { describePendingDisplays, pendingDisplayStatesInPage, waitForSession, } from './sessionGate.ts';
5
+ export { jbrowseUrl } from './url.ts';
6
+ export { assemblyFromSession, encodeSessionSpec, sessionSpecQuery, trackIdsFromSession, } from './session.ts';
7
+ export { ANIMATING_DISPLAYS, PENDING_DISPLAYS, displayPainted, displaySettled, waitForAppReady, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForSelectorAttributed, waitForViewPhases, } from './waits.ts';
8
+ export { BASE_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.ts';
9
9
  export type { CaptureOptions, CaptureResult, OpenOptions, OpenResult, } from './capture.ts';
10
10
  export type { ReadyOptions, ReadyReport } from './ready.ts';
11
- export type { PendingDisplay, SessionExpectations, SessionSummary, } from './sessionGate.ts';
11
+ export type { PendingDisplay, SessionExpectations } from './sessionGate.ts';
12
12
  export type { JBrowseUrlOptions } from './url.ts';
package/esm/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  export { captureJBrowse, openJBrowse } from './capture.js';
2
- export { waitForJBrowseReady } from './ready.js';
3
- export { APP_CENSUS, describePendingDisplays, pendingDisplayStates, pendingDisplayStatesInPage, pendingDisplays, readSessionSummary, waitForSession, } from './sessionGate.js';
4
- export { PUBLIC_INSTANCE, jbrowseUrl } from './url.js';
5
- export { fetchHubConfig, hubUrl, listHubTracks } from './hub.js';
6
- export { assemblyFromSession, encodeSessionSpec, sessionSpecParam, sessionSpecQuery, trackIdsFromSession, } from './session.js';
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.js';
8
- export { BASE_CHROME_ARGS, SANDBOX_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.js';
2
+ export { delay } from './poll.js';
3
+ export { waitForFrame, waitForJBrowseReady } from './ready.js';
4
+ export { describePendingDisplays, pendingDisplayStatesInPage, waitForSession, } from './sessionGate.js';
5
+ export { jbrowseUrl } from './url.js';
6
+ export { assemblyFromSession, encodeSessionSpec, sessionSpecQuery, trackIdsFromSession, } from './session.js';
7
+ export { ANIMATING_DISPLAYS, PENDING_DISPLAYS, displayPainted, displaySettled, waitForAppReady, waitForAppSettled, waitForDisplayPhases, waitForDisplaysDone, waitForLoadingComplete, waitForQuiescent, waitForSelectorAttributed, waitForViewPhases, } from './waits.js';
8
+ export { BASE_CHROME_ARGS, findChromeExecutable, isBrowserConsoleNoise, } from './browser.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Refuse a JBrowse Web instance older than v5 before launching a browser at it.
3
+ *
4
+ * Every jbrowse-web build writes its version to `version.txt`. An instance that
5
+ * serves none, or serves its index page there, cannot be judged and passes; the
6
+ * session gate still fails on it, a timeout later.
7
+ */
8
+ export declare function assertSupportedInstance(instance: string): Promise<void>;
@@ -0,0 +1,24 @@
1
+ // v5 is the first release to publish `[data-app-phase]` and the census beside
2
+ // it, which are the only signals this package waits on.
3
+ const MIN_MAJOR = 5;
4
+ /**
5
+ * Refuse a JBrowse Web instance older than v5 before launching a browser at it.
6
+ *
7
+ * Every jbrowse-web build writes its version to `version.txt`. An instance that
8
+ * serves none, or serves its index page there, cannot be judged and passes; the
9
+ * session gate still fails on it, a timeout later.
10
+ */
11
+ export async function assertSupportedInstance(instance) {
12
+ const text = await fetch(new URL('version.txt', instance), {
13
+ signal: AbortSignal.timeout(10000),
14
+ })
15
+ .then(res => (res.ok ? res.text() : ''))
16
+ .catch(() => '');
17
+ const version = text.trim();
18
+ const major = /^(\d+)\.\d+\.\d+/.exec(version)?.[1];
19
+ if (major !== undefined && Number(major) < MIN_MAJOR) {
20
+ throw new Error(`${instance} serves JBrowse ${version}, and @jbrowse/capture needs v${MIN_MAJOR} or later: ` +
21
+ 'older builds publish no readiness marker to wait on. Point --instance at a ' +
22
+ `v${MIN_MAJOR} build.`);
23
+ }
24
+ }
package/esm/poll.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare const delay: (ms: number) => Promise<void>;
2
+ /**
3
+ * Poll `read` until it has answered true for `holdMs` without a break, or
4
+ * `timeout` runs out. A `read` that throws ends the wait with its error.
5
+ *
6
+ * Polled from Node rather than in the page: chrome throttles in-page timers and
7
+ * rAF once a tab is not visible, which is the state a headless capture sits in.
8
+ */
9
+ export declare function holdTrue(read: () => Promise<boolean>, { holdMs, timeout, pollMs, }: {
10
+ holdMs: number;
11
+ timeout: number;
12
+ pollMs: number;
13
+ }): Promise<boolean>;
package/esm/poll.js ADDED
@@ -0,0 +1,29 @@
1
+ export const delay = (ms) => new Promise(resolve => {
2
+ setTimeout(resolve, ms);
3
+ });
4
+ /**
5
+ * Poll `read` until it has answered true for `holdMs` without a break, or
6
+ * `timeout` runs out. A `read` that throws ends the wait with its error.
7
+ *
8
+ * Polled from Node rather than in the page: chrome throttles in-page timers and
9
+ * rAF once a tab is not visible, which is the state a headless capture sits in.
10
+ */
11
+ export async function holdTrue(read, { holdMs, timeout, pollMs, }) {
12
+ const deadline = Date.now() + timeout;
13
+ let since;
14
+ while (Date.now() < deadline) {
15
+ const ok = await read();
16
+ const now = Date.now();
17
+ if (ok) {
18
+ since ??= now;
19
+ if (now - since >= holdMs) {
20
+ return true;
21
+ }
22
+ }
23
+ else {
24
+ since = undefined;
25
+ }
26
+ await delay(pollMs);
27
+ }
28
+ return false;
29
+ }
package/esm/ready.d.ts CHANGED
@@ -17,14 +17,17 @@ export interface ReadyOptions extends SessionExpectations {
17
17
  allowUnsettled?: boolean;
18
18
  }
19
19
  export interface ReadyReport {
20
- /** Displays still reporting unpainted at the end of the wait. */
21
- pending: string[];
22
20
  /**
23
- * The same displays with the phase each one publishes, which is what
24
- * separates a slow fetch from a display that says it finished without
25
- * painting. Empty whenever `pending` is.
21
+ * Displays still reporting unpainted, or canceled, at the end of the wait,
22
+ * each with the phase it publishes: what separates a slow fetch from a
23
+ * display that says it finished without painting.
26
24
  */
27
- pendingStates: PendingDisplay[];
25
+ pending: PendingDisplay[];
26
+ /**
27
+ * Displays showing "too much data" instead of their features. Not a failure,
28
+ * so never in `unsettled`, but worth saying: the image shows a banner there.
29
+ */
30
+ tooLarge: PendingDisplay[];
28
31
  /** Wait stages that hit their timeout instead of being satisfied. */
29
32
  unsettled: string[];
30
33
  }
@@ -43,7 +46,15 @@ export interface ReadyReport {
43
46
  *
44
47
  * Then one negative gate that is only meaningful after them, and answers the
45
48
  * question the marker does not: the marker is about WORK, and a display whose
46
- * fetch failed is not working — it reads `ready` over an error banner.
47
- * `data-display-drawn` is the stricter gate.
49
+ * fetch failed is not working — it reads `ready` over an error banner, and over
50
+ * a user's cancel. `data-display-drawn` is the stricter gate, and the census
51
+ * after it names a canceled display whatever it drew.
52
+ */
53
+ export declare function waitForJBrowseReady(page: Page, options?: ReadyOptions): Promise<ReadyReport>;
54
+ /**
55
+ * The half of `waitForJBrowseReady` after the session gate: the marker held,
56
+ * every display painted, and the census of what did not. Run again after
57
+ * anything that changes the frame without changing the session, such as a
58
+ * viewport resize.
48
59
  */
49
- export declare function waitForJBrowseReady(page: Page, { timeout, settleMs, assembly, trackIds, allowUnsettled, }?: ReadyOptions): Promise<ReadyReport>;
60
+ export declare function waitForFrame(page: Page, { timeout, settleMs, allowUnsettled, }?: Omit<ReadyOptions, keyof SessionExpectations>): Promise<ReadyReport>;
package/esm/ready.js CHANGED
@@ -1,5 +1,6 @@
1
- import { describePendingDisplays, pendingDisplayStates, waitForSession, } from './sessionGate.js';
2
- import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplaysDone, } from './waits.js';
1
+ import { delay } from './poll.js';
2
+ import { describePendingDisplays, pendingDisplayStates, tooLargeDisplaysInPage, waitForSession, } from './sessionGate.js';
3
+ import { waitForAppSettled, waitForDisplaysDone } from './waits.js';
3
4
  /**
4
5
  * Wait until a JBrowse page has finished loading AND finished drawing.
5
6
  *
@@ -15,26 +16,25 @@ import { delay, hasAppReadyMarker, waitForAppSettled, waitForDisplaysDone, } fro
15
16
  *
16
17
  * Then one negative gate that is only meaningful after them, and answers the
17
18
  * 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.
19
+ * fetch failed is not working — it reads `ready` over an error banner, and over
20
+ * a user's cancel. `data-display-drawn` is the stricter gate, and the census
21
+ * after it names a canceled display whatever it drew.
20
22
  */
21
- export async function waitForJBrowseReady(page, { timeout = 60000, settleMs = 0, assembly, trackIds, allowUnsettled = false, } = {}) {
23
+ export async function waitForJBrowseReady(page, options = {}) {
24
+ // The census the gate reads shares its element with `[data-app-phase]`, so
25
+ // once it passes the marker is there too. Handed the options whole, so every
26
+ // session expectation reaches it, `views` included.
27
+ await waitForSession(page, options);
28
+ return waitForFrame(page, options);
29
+ }
30
+ /**
31
+ * The half of `waitForJBrowseReady` after the session gate: the marker held,
32
+ * every display painted, and the census of what did not. Run again after
33
+ * anything that changes the frame without changing the session, such as a
34
+ * viewport resize.
35
+ */
36
+ export async function waitForFrame(page, { timeout = 60000, settleMs = 0, allowUnsettled = false, } = {}) {
22
37
  const unsettled = [];
23
- // 0. the session exists and holds what was asked for. Positive, and throws.
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.');
37
- }
38
38
  const ready = await waitForAppSettled(page, { timeout });
39
39
  if (!ready) {
40
40
  unsettled.push('the app never held itself ready');
@@ -56,18 +56,28 @@ export async function waitForJBrowseReady(page, { timeout = 60000, settleMs = 0,
56
56
  // that painted during `settleMs` was absent from `pending` and still failed
57
57
  // the run, while the CLI's own warning recommended raising `--settle` to fix
58
58
  // exactly that.
59
- const pendingStates = await pendingDisplayStates(page);
60
- if (pendingStates.length > 0) {
61
- unsettled.push(`display(s) never painted: ${describePendingDisplays(pendingStates)}`);
59
+ const pending = await pendingDisplayStates(page);
60
+ const canceled = pending.filter(d => d.phase === 'canceled');
61
+ const unpainted = pending.filter(d => d.phase !== 'canceled');
62
+ if (unpainted.length > 0) {
63
+ unsettled.push(`display(s) never painted: ${describePendingDisplays(unpainted)}`);
64
+ }
65
+ if (canceled.length > 0) {
66
+ unsettled.push(`display(s) canceled, which lasts until Retry: ${describePendingDisplays(canceled)}`);
62
67
  }
63
68
  if (!allowUnsettled && unsettled.length > 0) {
64
69
  // Throwing is the point. Each stage swallows its own timeout so a slow page
65
70
  // is not failed for being slow, which historically meant the run ended with
66
71
  // an image and an exit code of 0 whether it had settled or not. A caller
67
72
  // 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.');
73
+ throw new Error(unsettled.length === 1 && canceled.length > 0
74
+ ? `${unsettled[0]}. No timeout lifts a cancel; press Retry on the ` +
75
+ 'track, or pass allowUnsettled (--allowUnsettled) to capture the ' +
76
+ 'frame as it stands.'
77
+ : `gave up waiting after ${timeout}ms: ${unsettled.join('; ')}. ` +
78
+ 'Raise the timeout, or pass allowUnsettled (--allowUnsettled) to ' +
79
+ 'capture the frame as it stands.');
71
80
  }
72
- return { pending: pendingStates.map(d => d.name), pendingStates, unsettled };
81
+ const tooLarge = await page.evaluate(tooLargeDisplaysInPage);
82
+ return { pending, tooLarge, unsettled };
73
83
  }
package/esm/session.d.ts CHANGED
@@ -1,20 +1,28 @@
1
1
  export declare function sessionSpecParam(session: object): string;
2
2
  export declare function encodeSessionSpec(session: object): string;
3
3
  /**
4
- * The assembly a session spec's first view opens, if it names one.
4
+ * The `session=` value that opens a session saved with "Export session...".
5
+ * Takes the exported `{ session: {...} }` document or the snapshot inside it.
6
+ */
7
+ export declare function savedSessionParam(session: object): string;
8
+ /** The snapshot inside an exported session document, or the snapshot itself. */
9
+ export declare function savedSnapshot(session: object): object;
10
+ /**
11
+ * The assembly the first view of a session spec or saved snapshot opens, if it
12
+ * names one.
5
13
  *
6
14
  * The session gate needs this because a spec can be loaded against any config —
7
- * `--hub hg38 --session spec.json` where the spec opens a different assembly is
15
+ * `--hub hg38 --spec spec.json` where the spec opens a different assembly is
8
16
  * legitimate, and defaulting the expectation to the hub name would fail a
9
17
  * perfectly good capture.
10
18
  */
11
19
  export declare function assemblyFromSession(session: object): string | undefined;
12
20
  /**
13
- * Every trackId a session spec asks a view to open, including nested views (a
14
- * synteny or breakpoint-split spec puts its LGVs one level down).
21
+ * Every trackId a session spec or saved snapshot opens, including nested views
22
+ * (a synteny or breakpoint-split view puts its LGVs one level down).
15
23
  *
16
24
  * Used as the session gate's expectation: it is what turns "the browser loaded"
17
- * into "the tracks I named are open", and a spec is the one input where the
25
+ * into "the tracks I named are open", and a session is the one input where the
18
26
  * caller has not spelled those ids out separately.
19
27
  */
20
28
  export declare function trackIdsFromSession(session: object): string[];
package/esm/session.js CHANGED
@@ -15,17 +15,33 @@ export function encodeSessionSpec(session) {
15
15
  return encodeURIComponent(sessionSpecParam(session));
16
16
  }
17
17
  /**
18
- * The assembly a session spec's first view opens, if it names one.
18
+ * The `session=` value that opens a session saved with "Export session...".
19
+ * Takes the exported `{ session: {...} }` document or the snapshot inside it.
20
+ */
21
+ export function savedSessionParam(session) {
22
+ return `json-${JSON.stringify({ session: savedSnapshot(session) })}`;
23
+ }
24
+ /** The snapshot inside an exported session document, or the snapshot itself. */
25
+ export function savedSnapshot(session) {
26
+ const inner = session.session;
27
+ return typeof inner === 'object' && inner !== null ? inner : session;
28
+ }
29
+ /**
30
+ * The assembly the first view of a session spec or saved snapshot opens, if it
31
+ * names one.
19
32
  *
20
33
  * The session gate needs this because a spec can be loaded against any config —
21
- * `--hub hg38 --session spec.json` where the spec opens a different assembly is
34
+ * `--hub hg38 --spec spec.json` where the spec opens a different assembly is
22
35
  * legitimate, and defaulting the expectation to the hub name would fail a
23
36
  * perfectly good capture.
24
37
  */
25
38
  export function assemblyFromSession(session) {
26
39
  const find = (views) => {
27
40
  for (const view of views ?? []) {
28
- const found = view.assembly ?? find(view.views);
41
+ const found = [view.assembly ?? []].flat()[0] ??
42
+ view.displayedRegions?.[0]?.assemblyName ??
43
+ view.assemblyNames?.[0] ??
44
+ find(view.views);
29
45
  if (found) {
30
46
  return found;
31
47
  }
@@ -35,20 +51,23 @@ export function assemblyFromSession(session) {
35
51
  return find(session.views);
36
52
  }
37
53
  /**
38
- * Every trackId a session spec asks a view to open, including nested views (a
39
- * synteny or breakpoint-split spec puts its LGVs one level down).
54
+ * Every trackId a session spec or saved snapshot opens, including nested views
55
+ * (a synteny or breakpoint-split view puts its LGVs one level down).
40
56
  *
41
57
  * Used as the session gate's expectation: it is what turns "the browser loaded"
42
- * into "the tracks I named are open", and a spec is the one input where the
58
+ * into "the tracks I named are open", and a session is the one input where the
43
59
  * caller has not spelled those ids out separately.
44
60
  */
45
61
  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);
62
+ // an entry that names no trackId is malformed rather than a track to expect,
63
+ // so it drops out here instead of gating on undefined
64
+ const idOf = (t) => typeof t === 'string'
65
+ ? t
66
+ : (t.trackId ??
67
+ (typeof t.configuration === 'string'
68
+ ? t.configuration
69
+ : t.configuration?.trackId));
70
+ const idsOf = (tracks) => (tracks ?? []).map(idOf).filter(id => id !== undefined);
52
71
  const collect = (views) => (views ?? []).flatMap(view => [
53
72
  // a nested entry is a synteny spec's per-level list; these used to be
54
73
  // filtered out as malformed, so a synteny --session gated on nothing