@juxtapose/core 0.2.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/agent/handoff.d.ts +30 -0
- package/dist/agent/handoff.js +121 -0
- package/dist/agent/handoff.js.map +1 -0
- package/dist/analysis/affected.d.ts +19 -0
- package/dist/analysis/affected.js +132 -0
- package/dist/analysis/affected.js.map +1 -0
- package/dist/baseline/git-store.d.ts +26 -0
- package/dist/baseline/git-store.js +95 -0
- package/dist/baseline/git-store.js.map +1 -0
- package/dist/baseline/status.d.ts +22 -0
- package/dist/baseline/status.js +49 -0
- package/dist/baseline/status.js.map +1 -0
- package/dist/capture/capture.d.ts +32 -0
- package/dist/capture/capture.js +153 -0
- package/dist/capture/capture.js.map +1 -0
- package/dist/capture/launch.d.ts +35 -0
- package/dist/capture/launch.js +56 -0
- package/dist/capture/launch.js.map +1 -0
- package/dist/capture/pool.d.ts +42 -0
- package/dist/capture/pool.js +109 -0
- package/dist/capture/pool.js.map +1 -0
- package/dist/capture/settle.d.ts +50 -0
- package/dist/capture/settle.js +162 -0
- package/dist/capture/settle.js.map +1 -0
- package/dist/diff/diff.d.ts +25 -0
- package/dist/diff/diff.js +86 -0
- package/dist/diff/diff.js.map +1 -0
- package/dist/diff/regions.d.ts +8 -0
- package/dist/diff/regions.js +90 -0
- package/dist/diff/regions.js.map +1 -0
- package/dist/git/ancestry.d.ts +5 -0
- package/dist/git/ancestry.js +43 -0
- package/dist/git/ancestry.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +83 -0
- package/dist/platform.js +5 -0
- package/dist/platform.js.map +1 -0
- package/dist/report/comment.d.ts +16 -0
- package/dist/report/comment.js +99 -0
- package/dist/report/comment.js.map +1 -0
- package/dist/report/github.d.ts +28 -0
- package/dist/report/github.js +118 -0
- package/dist/report/github.js.map +1 -0
- package/dist/run/run.d.ts +30 -0
- package/dist/run/run.js +120 -0
- package/dist/run/run.js.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/url-platform.d.ts +10 -0
- package/dist/url-platform.js +22 -0
- package/dist/url-platform.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Capture one story. The ordering here is load-bearing — see settle.ts for why
|
|
2
|
+
// each step exists. Because pass/fail is a byte-exact hash, every source of
|
|
3
|
+
// nondeterminism left in this path becomes a false failure somewhere.
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { armSettle, drain, FREEZE_ANIMATIONS_CSS, waitForPaint, waitForSelector, } from './settle.js';
|
|
6
|
+
const DEFAULT_CAPTURE_TIMEOUT_MS = 2000;
|
|
7
|
+
/** Per-page memo: skip work that only needs doing once per page. */
|
|
8
|
+
const styleInjected = new WeakSet();
|
|
9
|
+
const pageViewport = new WeakMap();
|
|
10
|
+
const warmPages = new WeakMap();
|
|
11
|
+
export async function captureStory(page, story, deps, viewport) {
|
|
12
|
+
const started = Date.now();
|
|
13
|
+
// Copied, not aliased: live params get merged in below, and mutating the
|
|
14
|
+
// manifest entry would leak one story's config into the next run.
|
|
15
|
+
const params = { ...story.params };
|
|
16
|
+
const effectiveViewport = params.viewport ?? viewport;
|
|
17
|
+
const timeoutMs = params.captureTimeout ?? DEFAULT_CAPTURE_TIMEOUT_MS;
|
|
18
|
+
const ctx = { story, params, timeoutMs };
|
|
19
|
+
await ensureViewport(page, effectiveViewport);
|
|
20
|
+
// Arm before triggering the render, or the observer misses the mutations it
|
|
21
|
+
// exists to watch.
|
|
22
|
+
//
|
|
23
|
+
// A story that wants animations running can't reuse a page whose stylesheet
|
|
24
|
+
// already froze them, so force the cold path for those.
|
|
25
|
+
const warm = deps.warmSwitch === true &&
|
|
26
|
+
warmPages.get(page) === deps.baseUrl &&
|
|
27
|
+
!(params.animations === 'play' && styleInjected.has(page));
|
|
28
|
+
if (warm)
|
|
29
|
+
await armSettle(page);
|
|
30
|
+
const switched = warm && deps.platform.switchStory
|
|
31
|
+
? await deps.platform.switchStory(page, story.storyId, ctx)
|
|
32
|
+
: false;
|
|
33
|
+
if (!switched) {
|
|
34
|
+
// Delegated rather than built here: not every platform addresses a story
|
|
35
|
+
// by URL. Playwright galleries render via window.mount() on a fixed page.
|
|
36
|
+
await deps.platform.showStory(page, story.storyId, deps.baseUrl);
|
|
37
|
+
// A navigation discards injected style tags, so the freeze must be
|
|
38
|
+
// re-applied for the next story that wants it.
|
|
39
|
+
styleInjected.delete(page);
|
|
40
|
+
warmPages.set(page, deps.baseUrl);
|
|
41
|
+
await armSettle(page);
|
|
42
|
+
}
|
|
43
|
+
// Freeze BEFORE the story renders, not after.
|
|
44
|
+
//
|
|
45
|
+
// This ordering is the whole ballgame for warm-page reuse. Injected styles
|
|
46
|
+
// survive a story switch but not a navigation, so freezing after render
|
|
47
|
+
// means a warm story renders with the previous story's freeze already
|
|
48
|
+
// active while a cold story renders unfrozen — and the two paths produce
|
|
49
|
+
// different pixels for the same story. Under a byte-exact hash that makes a
|
|
50
|
+
// story's result depend on where it landed in the shuffled queue, which is
|
|
51
|
+
// to say: random.
|
|
52
|
+
//
|
|
53
|
+
// Injecting here makes both paths see identical conditions. Measured on a
|
|
54
|
+
// 16-story suite: 2-4 stories failing per run before, 16/16 stable after.
|
|
55
|
+
await freezeAnimations(page, params);
|
|
56
|
+
await deps.platform.settle(page, ctx);
|
|
57
|
+
// Now that the story has rendered, ask the platform whether it declares
|
|
58
|
+
// params the static index didn't carry.
|
|
59
|
+
//
|
|
60
|
+
// Storybook is why this exists: it only serializes a whitelist of parameters
|
|
61
|
+
// into index.json, so `parameters.juxtapose` is essentially never there.
|
|
62
|
+
// Reading the index alone silently dropped every per-story threshold,
|
|
63
|
+
// waitForSelector and skip on that platform.
|
|
64
|
+
//
|
|
65
|
+
// Live params override manifest params — the live preview is the story's own
|
|
66
|
+
// declaration, and the index is a lossy projection of it.
|
|
67
|
+
const live = await deps.platform.readLiveParams?.(page, story.storyId);
|
|
68
|
+
if (live) {
|
|
69
|
+
Object.assign(params, live);
|
|
70
|
+
// Viewport is the one param that cannot be applied retroactively — the
|
|
71
|
+
// story has already laid out. Re-set it and let the platform settle
|
|
72
|
+
// again, so a story declaring its own viewport is captured at that size
|
|
73
|
+
// rather than the project default.
|
|
74
|
+
const liveViewport = live.viewport;
|
|
75
|
+
if (liveViewport && !sameViewport(liveViewport, effectiveViewport)) {
|
|
76
|
+
await ensureViewport(page, liveViewport);
|
|
77
|
+
await deps.platform.settle(page, { ...ctx, params });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// A story can declare `skip` in params the static index never carried, so
|
|
81
|
+
// this is the first point we can honour it. Signalled rather than returning
|
|
82
|
+
// a snapshot, because capturing and then discarding would still cost the
|
|
83
|
+
// screenshot and would report a meaningless hash.
|
|
84
|
+
if (params.skip === true)
|
|
85
|
+
return null;
|
|
86
|
+
if (params.waitForSelector) {
|
|
87
|
+
await waitForSelector(page, params.waitForSelector, timeoutMs);
|
|
88
|
+
}
|
|
89
|
+
await drain(page);
|
|
90
|
+
// Fixed delay is the escape hatch of last resort — it costs the same on
|
|
91
|
+
// every run whether or not it was needed. Prefer waitForSelector.
|
|
92
|
+
if (params.delay && params.delay > 0) {
|
|
93
|
+
await page.waitForTimeout(params.delay);
|
|
94
|
+
}
|
|
95
|
+
// Last barrier before the shutter: the DOM being quiet does not mean the
|
|
96
|
+
// pixels are final. Style, layout and paint still have to land, and without
|
|
97
|
+
// this the capture intermittently catches an intermediate frame — which a
|
|
98
|
+
// byte-exact hash reports as a failure.
|
|
99
|
+
await waitForPaint(page);
|
|
100
|
+
const png = await page.screenshot({ type: 'png' });
|
|
101
|
+
// LOSSLESS, deliberately, and it is not a size-vs-quality preference.
|
|
102
|
+
//
|
|
103
|
+
// Lossy WebP at q90 perturbs ~7.7% of pixels frame-wide. The hash comparison
|
|
104
|
+
// survives that (encoding is deterministic, so identical input still yields
|
|
105
|
+
// identical bytes), but the pixel diff does not: comparing two lossy decodes
|
|
106
|
+
// scatters compression noise across every row and column, which swallows the
|
|
107
|
+
// real changed region into a full-viewport bounding box and destroys the
|
|
108
|
+
// localization the diff exists to provide.
|
|
109
|
+
//
|
|
110
|
+
// Measured: 3537 changed pixels from a single recoloured button spread over
|
|
111
|
+
// all 800 rows and all 1280 columns. With lossless, zero pixels differ from
|
|
112
|
+
// the source. Costs ~4x the bytes; buys a diff that means something.
|
|
113
|
+
const image = await sharp(png)
|
|
114
|
+
.webp({ lossless: true, effort: deps.webpEffort ?? 0 })
|
|
115
|
+
.toBuffer();
|
|
116
|
+
return {
|
|
117
|
+
storyId: story.storyId,
|
|
118
|
+
image,
|
|
119
|
+
// The live params may have widened or narrowed this after first render,
|
|
120
|
+
// so report what we actually captured at — the agent handoff computes
|
|
121
|
+
// region area as a fraction of it.
|
|
122
|
+
viewport: params.viewport ?? effectiveViewport,
|
|
123
|
+
capturedAt: new Date().toISOString(),
|
|
124
|
+
durationMs: Date.now() - started,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const sameViewport = (a, b) => a.width === b.width && a.height === b.height;
|
|
128
|
+
/** Setting the viewport is a CDP round-trip; skip it when nothing changed. */
|
|
129
|
+
async function ensureViewport(page, viewport) {
|
|
130
|
+
const key = `${viewport.width}x${viewport.height}`;
|
|
131
|
+
if (pageViewport.get(page) === key)
|
|
132
|
+
return;
|
|
133
|
+
await page.setViewportSize(viewport);
|
|
134
|
+
pageViewport.set(page, key);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Inject the freeze stylesheet, once per page.
|
|
138
|
+
*
|
|
139
|
+
* Note the interaction with warm-page reuse: the style tag survives a story
|
|
140
|
+
* switch but not a navigation, so a story with `animations: 'play'` landing on
|
|
141
|
+
* an already-frozen page would still be frozen. The warm check above excludes
|
|
142
|
+
* those stories for that reason, forcing them down the cold path where the
|
|
143
|
+
* navigation clears the tag.
|
|
144
|
+
*/
|
|
145
|
+
async function freezeAnimations(page, params) {
|
|
146
|
+
if (params.animations === 'play')
|
|
147
|
+
return;
|
|
148
|
+
if (styleInjected.has(page))
|
|
149
|
+
return;
|
|
150
|
+
await page.addStyleTag({ content: FREEZE_ANIMATIONS_CSS }).catch(() => { });
|
|
151
|
+
styleInjected.add(page);
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=capture.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capture.js","sourceRoot":"","sources":["../../src/capture/capture.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium flags that make rasterization reproducible across runs.
|
|
3
|
+
*
|
|
4
|
+
* Each is here for a reason; none are cargo-culted:
|
|
5
|
+
*
|
|
6
|
+
* - `--disable-gpu`, `--disable-gpu-rasterization`, `--disable-skia-runtime-opts`
|
|
7
|
+
* force CPU rasterization down a fixed code path. GPU rasterization is the
|
|
8
|
+
* direct cause of the off-by-one antialiasing jitter above, and Skia's
|
|
9
|
+
* runtime CPU-feature detection can pick different SIMD paths on different
|
|
10
|
+
* machines.
|
|
11
|
+
*
|
|
12
|
+
* - `--force-color-profile=srgb`, `--disable-lcd-text`, `--disable-font-subpixel-positioning`
|
|
13
|
+
* pin colour management and text rendering. Subpixel positioning and LCD
|
|
14
|
+
* antialiasing make glyph edges depend on display configuration, which is
|
|
15
|
+
* exactly the kind of environment coupling that makes a baseline captured on
|
|
16
|
+
* one machine fail on another.
|
|
17
|
+
*
|
|
18
|
+
* - `--hide-scrollbars` keeps a scrollbar's presence from shifting layout by a
|
|
19
|
+
* few pixels depending on content height.
|
|
20
|
+
*
|
|
21
|
+
* - `--disable-background-timer-throttling` and friends stop Chromium from
|
|
22
|
+
* throttling a backgrounded renderer, which otherwise makes settle timing
|
|
23
|
+
* depend on whether the window happens to be focused.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DETERMINISTIC_CHROMIUM_ARGS: readonly string[];
|
|
26
|
+
export interface LaunchOptions {
|
|
27
|
+
/** Extra flags, appended after the deterministic set so they can override. */
|
|
28
|
+
args?: string[];
|
|
29
|
+
headless?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Build the Playwright launch options for a reproducible capture browser. */
|
|
32
|
+
export declare function deterministicLaunchOptions(opts?: LaunchOptions): {
|
|
33
|
+
headless: boolean;
|
|
34
|
+
args: string[];
|
|
35
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Browser launch flags for reproducible rasterization.
|
|
2
|
+
//
|
|
3
|
+
// These matter more here than in most Playwright setups. Pass/fail is a
|
|
4
|
+
// byte-exact hash, so a single pixel of GPU rasterization jitter on one
|
|
5
|
+
// antialiased corner is a failed build — not a 0.002% mismatch someone can
|
|
6
|
+
// wave through.
|
|
7
|
+
//
|
|
8
|
+
// Measured on a card component with rounded corners: without these flags,
|
|
9
|
+
// repeated captures of an unchanged story differed by ~12 pixels out of
|
|
10
|
+
// 480,000, each off by exactly 1 in one channel. Invisible to a human, fatal
|
|
11
|
+
// to a hash.
|
|
12
|
+
/**
|
|
13
|
+
* Chromium flags that make rasterization reproducible across runs.
|
|
14
|
+
*
|
|
15
|
+
* Each is here for a reason; none are cargo-culted:
|
|
16
|
+
*
|
|
17
|
+
* - `--disable-gpu`, `--disable-gpu-rasterization`, `--disable-skia-runtime-opts`
|
|
18
|
+
* force CPU rasterization down a fixed code path. GPU rasterization is the
|
|
19
|
+
* direct cause of the off-by-one antialiasing jitter above, and Skia's
|
|
20
|
+
* runtime CPU-feature detection can pick different SIMD paths on different
|
|
21
|
+
* machines.
|
|
22
|
+
*
|
|
23
|
+
* - `--force-color-profile=srgb`, `--disable-lcd-text`, `--disable-font-subpixel-positioning`
|
|
24
|
+
* pin colour management and text rendering. Subpixel positioning and LCD
|
|
25
|
+
* antialiasing make glyph edges depend on display configuration, which is
|
|
26
|
+
* exactly the kind of environment coupling that makes a baseline captured on
|
|
27
|
+
* one machine fail on another.
|
|
28
|
+
*
|
|
29
|
+
* - `--hide-scrollbars` keeps a scrollbar's presence from shifting layout by a
|
|
30
|
+
* few pixels depending on content height.
|
|
31
|
+
*
|
|
32
|
+
* - `--disable-background-timer-throttling` and friends stop Chromium from
|
|
33
|
+
* throttling a backgrounded renderer, which otherwise makes settle timing
|
|
34
|
+
* depend on whether the window happens to be focused.
|
|
35
|
+
*/
|
|
36
|
+
export const DETERMINISTIC_CHROMIUM_ARGS = [
|
|
37
|
+
'--disable-gpu',
|
|
38
|
+
'--disable-gpu-rasterization',
|
|
39
|
+
'--disable-skia-runtime-opts',
|
|
40
|
+
'--disable-lcd-text',
|
|
41
|
+
'--disable-font-subpixel-positioning',
|
|
42
|
+
'--force-color-profile=srgb',
|
|
43
|
+
'--hide-scrollbars',
|
|
44
|
+
'--disable-background-timer-throttling',
|
|
45
|
+
'--disable-backgrounding-occluded-windows',
|
|
46
|
+
'--disable-renderer-backgrounding',
|
|
47
|
+
'--force-device-scale-factor=1',
|
|
48
|
+
];
|
|
49
|
+
/** Build the Playwright launch options for a reproducible capture browser. */
|
|
50
|
+
export function deterministicLaunchOptions(opts = {}) {
|
|
51
|
+
return {
|
|
52
|
+
headless: opts.headless ?? true,
|
|
53
|
+
args: [...DETERMINISTIC_CHROMIUM_ARGS, ...(opts.args ?? [])],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=launch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"launch.js","sourceRoot":"","sources":["../../src/capture/launch.ts"],"names":[],"mappings":"AAAA,uDAAuD;AACvD,EAAE;AACF,wEAAwE;AACxE,wEAAwE;AACxE,2EAA2E;AAC3E,gBAAgB;AAChB,EAAE;AACF,0EAA0E;AAC1E,wEAAwE;AACxE,6EAA6E;AAC7E,aAAa;AAEb;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAsB;IAC5D,eAAe;IACf,6BAA6B;IAC7B,6BAA6B;IAC7B,oBAAoB;IACpB,qCAAqC;IACrC,4BAA4B;IAC5B,mBAAmB;IACnB,uCAAuC;IACvC,0CAA0C;IAC1C,kCAAkC;IAClC,+BAA+B;CAChC,CAAC;AAQF,8EAA8E;AAC9E,MAAM,UAAU,0BAA0B,CAAC,OAAsB,EAAE;IAIjE,OAAO;QACL,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI;QAC/B,IAAI,EAAE,CAAC,GAAG,2BAA2B,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;KAC7D,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Browser } from 'playwright';
|
|
2
|
+
import type { Platform } from '../platform.js';
|
|
3
|
+
import type { Snapshot, StoryManifest, Viewport } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Default parallelism.
|
|
6
|
+
*
|
|
7
|
+
* Empirically 8 on a 10-core machine (~80% utilization). This is NOT a
|
|
8
|
+
* "higher is better" knob: at 16+ the render-ready timeout starts firing from
|
|
9
|
+
* CPU contention rather than real story failures, and 32 measured ~50% SLOWER
|
|
10
|
+
* than 16 on the same suite. Scale to core count, not to infinity.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_CONCURRENCY = 8;
|
|
13
|
+
export interface PoolOptions {
|
|
14
|
+
browser: Browser;
|
|
15
|
+
platform: Platform;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
concurrency?: number;
|
|
18
|
+
viewport: Viewport;
|
|
19
|
+
/** Called as each story finishes, for progress reporting. */
|
|
20
|
+
onProgress?: (done: number, total: number, storyId: string) => void;
|
|
21
|
+
/** Cooperative cancellation, checked at story boundaries. */
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
/** Reuse warm pages and switch stories in place. Faster, but does not
|
|
24
|
+
* currently reproduce cold-load pixels exactly — see CaptureDeps. */
|
|
25
|
+
warmSwitch?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface CaptureOutcome {
|
|
28
|
+
story: StoryManifest;
|
|
29
|
+
snapshot?: Snapshot;
|
|
30
|
+
error?: Error;
|
|
31
|
+
/** The story asked to be skipped via params the index didn't carry. */
|
|
32
|
+
skipped?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Capture stories across N parallel pages, yielding results as they complete.
|
|
36
|
+
*
|
|
37
|
+
* Each worker owns its own BrowserContext. Sharing one context across workers
|
|
38
|
+
* was measured at ~0% faster and leaks state between concurrently-rendering
|
|
39
|
+
* stories — most visibly a service worker registered by one story racing
|
|
40
|
+
* another's navigation. Isolation wins on a tie.
|
|
41
|
+
*/
|
|
42
|
+
export declare function capturePool(stories: StoryManifest[], opts: PoolOptions): AsyncGenerator<CaptureOutcome>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Worker pool: fan stories across N parallel pages, yielding results as they
|
|
2
|
+
// complete. Used by both the CLI and the dev-server review API, which is why
|
|
3
|
+
// it takes a Browser rather than owning one.
|
|
4
|
+
import { captureStory } from './capture.js';
|
|
5
|
+
/**
|
|
6
|
+
* Default parallelism.
|
|
7
|
+
*
|
|
8
|
+
* Empirically 8 on a 10-core machine (~80% utilization). This is NOT a
|
|
9
|
+
* "higher is better" knob: at 16+ the render-ready timeout starts firing from
|
|
10
|
+
* CPU contention rather than real story failures, and 32 measured ~50% SLOWER
|
|
11
|
+
* than 16 on the same suite. Scale to core count, not to infinity.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_CONCURRENCY = 8;
|
|
14
|
+
/**
|
|
15
|
+
* Capture stories across N parallel pages, yielding results as they complete.
|
|
16
|
+
*
|
|
17
|
+
* Each worker owns its own BrowserContext. Sharing one context across workers
|
|
18
|
+
* was measured at ~0% faster and leaks state between concurrently-rendering
|
|
19
|
+
* stories — most visibly a service worker registered by one story racing
|
|
20
|
+
* another's navigation. Isolation wins on a tie.
|
|
21
|
+
*/
|
|
22
|
+
export async function* capturePool(stories, opts) {
|
|
23
|
+
const queue = stories.filter((s) => s.params.skip !== true);
|
|
24
|
+
if (queue.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
27
|
+
const workerCount = Math.min(concurrency, queue.length);
|
|
28
|
+
// Shuffle so stories from one slow component don't cluster at the tail and
|
|
29
|
+
// serialize the final stretch on a single worker. Skipped at concurrency 1,
|
|
30
|
+
// where ordering is deterministic and there's nothing to interleave.
|
|
31
|
+
const work = workerCount > 1 ? shuffle(queue) : queue;
|
|
32
|
+
let cursor = 0;
|
|
33
|
+
let done = 0;
|
|
34
|
+
const buffer = [];
|
|
35
|
+
let notify = null;
|
|
36
|
+
const push = (out) => {
|
|
37
|
+
buffer.push(out);
|
|
38
|
+
notify?.();
|
|
39
|
+
notify = null;
|
|
40
|
+
};
|
|
41
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
42
|
+
const context = await opts.browser.newContext({
|
|
43
|
+
viewport: opts.viewport,
|
|
44
|
+
// No Retina. Quadruples buffer size for no diff signal.
|
|
45
|
+
deviceScaleFactor: 1,
|
|
46
|
+
});
|
|
47
|
+
const page = await context.newPage();
|
|
48
|
+
const deps = {
|
|
49
|
+
platform: opts.platform,
|
|
50
|
+
baseUrl: opts.baseUrl,
|
|
51
|
+
warmSwitch: opts.warmSwitch,
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (opts.signal?.aborted)
|
|
56
|
+
break;
|
|
57
|
+
const i = cursor++;
|
|
58
|
+
if (i >= work.length)
|
|
59
|
+
break;
|
|
60
|
+
const story = work[i];
|
|
61
|
+
try {
|
|
62
|
+
const snapshot = await captureStory(page, story, deps, opts.viewport);
|
|
63
|
+
// null means the story declared `skip` in params only visible once
|
|
64
|
+
// it had rendered. Report it as skipped, not as a failure.
|
|
65
|
+
push(snapshot ? { story, snapshot } : { story, skipped: true });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// One bad story must never kill a run of 700. Report it as a failure
|
|
69
|
+
// for this story and keep the worker going.
|
|
70
|
+
push({ story, error: err instanceof Error ? err : new Error(String(err)) });
|
|
71
|
+
}
|
|
72
|
+
opts.onProgress?.(++done, work.length, story.storyId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await context.close().catch(() => { });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const all = Promise.all(workers);
|
|
80
|
+
let finished = false;
|
|
81
|
+
void all.then(() => {
|
|
82
|
+
finished = true;
|
|
83
|
+
notify?.();
|
|
84
|
+
notify = null;
|
|
85
|
+
}, () => {
|
|
86
|
+
finished = true;
|
|
87
|
+
notify?.();
|
|
88
|
+
notify = null;
|
|
89
|
+
});
|
|
90
|
+
while (!finished || buffer.length > 0) {
|
|
91
|
+
if (buffer.length === 0) {
|
|
92
|
+
await new Promise((resolve) => {
|
|
93
|
+
notify = resolve;
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
yield buffer.shift();
|
|
98
|
+
}
|
|
99
|
+
await all;
|
|
100
|
+
}
|
|
101
|
+
function shuffle(items) {
|
|
102
|
+
const out = items.slice();
|
|
103
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
104
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
105
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=pool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pool.js","sourceRoot":"","sources":["../../src/capture/pool.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,6EAA6E;AAC7E,6CAA6C;AAK7C,OAAO,EAAE,YAAY,EAAoB,MAAM,cAAc,CAAC;AAE9D;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAyBrC;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,WAAW,CAChC,OAAwB,EACxB,IAAiB;IAEjB,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;IAC5D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAE/B,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,WAAW,IAAI,mBAAmB,CAAC,CAAC;IACzE,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAExD,2EAA2E;IAC3E,4EAA4E;IAC5E,qEAAqE;IACrE,MAAM,IAAI,GAAG,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IAEtD,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,MAAM,MAAM,GAAqB,EAAE,CAAC;IACpC,IAAI,MAAM,GAAwB,IAAI,CAAC;IAEvC,MAAM,IAAI,GAAG,CAAC,GAAmB,EAAE,EAAE;QACnC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjB,MAAM,EAAE,EAAE,CAAC;QACX,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,KAAK,IAAI,EAAE;QAC7D,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;YAC5C,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,wDAAwD;YACxD,iBAAiB,EAAE,CAAC;SACrB,CAAC,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;QACrC,MAAM,IAAI,GAAgB;YACxB,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,UAAU,EAAE,IAAI,CAAC,UAAU;SAC5B,CAAC;QAEF,IAAI,CAAC;YACH,SAAS,CAAC;gBACR,IAAI,IAAI,CAAC,MAAM,EAAE,OAAO;oBAAE,MAAM;gBAChC,MAAM,CAAC,GAAG,MAAM,EAAE,CAAC;gBACnB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM;oBAAE,MAAM;gBAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;gBAEtB,IAAI,CAAC;oBACH,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;oBACtE,mEAAmE;oBACnE,2DAA2D;oBAC3D,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;gBAClE,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,qEAAqE;oBACrE,4CAA4C;oBAC5C,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC9E,CAAC;gBAED,IAAI,CAAC,UAAU,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YACxD,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACxC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACjC,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,KAAK,GAAG,CAAC,IAAI,CACX,GAAG,EAAE;QACH,QAAQ,GAAG,IAAI,CAAC;QAChB,MAAM,EAAE,EAAE,CAAC;QACX,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC,EACD,GAAG,EAAE;QACH,QAAQ,GAAG,IAAI,CAAC;QAChB,MAAM,EAAE,EAAE,CAAC;QACX,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC,CACF,CAAC;IAEF,OAAO,CAAC,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;gBAClC,MAAM,GAAG,OAAO,CAAC;YACnB,CAAC,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QACD,MAAM,MAAM,CAAC,KAAK,EAAG,CAAC;IACxB,CAAC;IAED,MAAM,GAAG,CAAC;AACZ,CAAC;AAED,SAAS,OAAO,CAAI,KAAU;IAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC;IAC1B,KAAK,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC9C,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
/** Quiet period with no DOM mutations and no in-flight fetches before we
|
|
3
|
+
* declare the page settled. */
|
|
4
|
+
export declare const DOM_IDLE_MS = 150;
|
|
5
|
+
/** Hard cap on waiting for quiescence. A story that never goes idle — a
|
|
6
|
+
* spinner that survived the CSS freeze, a polling widget — still gets
|
|
7
|
+
* captured rather than hanging the run. Kept tight: this cost is paid on
|
|
8
|
+
* every such story, times the whole suite. */
|
|
9
|
+
export declare const DOM_IDLE_TIMEOUT_MS = 500;
|
|
10
|
+
/** Arm the quiescence observer. Call before triggering a story render. */
|
|
11
|
+
export declare function armSettle(page: Page): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Await quiescence, webfonts and image decode in a single round trip.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately one `evaluate` rather than three. At ~1.2s per story and 8
|
|
16
|
+
* parallel pages, CDP round-trips are a measurable share of wall clock; the
|
|
17
|
+
* original tool cut per-story time meaningfully by collapsing these.
|
|
18
|
+
*/
|
|
19
|
+
export declare function drain(page: Page): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Zero out animations and transitions.
|
|
22
|
+
*
|
|
23
|
+
* Without this, anything mid-animation at screenshot time — spinners, skeleton
|
|
24
|
+
* shimmer, fade-ins, the text caret — makes the story diff against itself on
|
|
25
|
+
* every run. Stories that exist specifically to test animation opt out with
|
|
26
|
+
* `animations: 'play'`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const FREEZE_ANIMATIONS_CSS = "\n*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n caret-color: transparent !important;\n}\n";
|
|
29
|
+
/** Wait for an explicit selector. Escape hatch for async content the network
|
|
30
|
+
* drain can't observe — SDK init, canvas paint, third-party widgets. */
|
|
31
|
+
export declare function waitForSelector(page: Page, selector: string, timeoutMs: number): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Block until the compositor has actually presented a frame.
|
|
34
|
+
*
|
|
35
|
+
* The DOM going quiet is not the same as the pixels being final: style
|
|
36
|
+
* recalculation, layout and paint still have to happen, and a screenshot taken
|
|
37
|
+
* in that window catches an intermediate frame.
|
|
38
|
+
*
|
|
39
|
+
* This is not theoretical. Without it, repeated captures of an unchanged story
|
|
40
|
+
* differed by ~12 pixels out of 480,000 — each off by exactly 1 — on the
|
|
41
|
+
* antialiased edges of a rounded corner. Invisible to a human; a failed build
|
|
42
|
+
* under a byte-exact hash. Measured: flaky with no wait, stable from ~50ms,
|
|
43
|
+
* and stable with this barrier and no arbitrary delay at all.
|
|
44
|
+
*
|
|
45
|
+
* Two nested rAFs, then `requestIdleCallback`: the first rAF runs before the
|
|
46
|
+
* next paint, the second after it has been committed, and the idle callback
|
|
47
|
+
* gives any post-paint work a chance to drain. Cheap — a couple of frames at
|
|
48
|
+
* most — and it removes the need for a blanket sleep on every story.
|
|
49
|
+
*/
|
|
50
|
+
export declare function waitForPaint(page: Page, timeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Platform-neutral settling. Runs *after* the platform's own render signal
|
|
2
|
+
// (Platform.settle) has resolved. Everything here applies to any DOM, which is
|
|
3
|
+
// why it lives in core rather than in an adapter.
|
|
4
|
+
//
|
|
5
|
+
// The ordering matters and is not arbitrary:
|
|
6
|
+
// arm → platform render signal → DOM/network quiescence → fonts → images
|
|
7
|
+
// Arming must happen before the render is triggered, or the observer misses
|
|
8
|
+
// the mutations it is supposed to be watching.
|
|
9
|
+
/** Quiet period with no DOM mutations and no in-flight fetches before we
|
|
10
|
+
* declare the page settled. */
|
|
11
|
+
export const DOM_IDLE_MS = 150;
|
|
12
|
+
/** Hard cap on waiting for quiescence. A story that never goes idle — a
|
|
13
|
+
* spinner that survived the CSS freeze, a polling widget — still gets
|
|
14
|
+
* captured rather than hanging the run. Kept tight: this cost is paid on
|
|
15
|
+
* every such story, times the whole suite. */
|
|
16
|
+
export const DOM_IDLE_TIMEOUT_MS = 500;
|
|
17
|
+
/**
|
|
18
|
+
* Installed via evaluate before each story render. Resolves `window.__juxWait`
|
|
19
|
+
* once the DOM has been mutation-free for `idleMs`, or `timeoutMs` elapses.
|
|
20
|
+
*
|
|
21
|
+
* Counts in-flight fetches too: a story that fires a request on mount would
|
|
22
|
+
* otherwise report "idle" in the gap between mount and response, and we'd
|
|
23
|
+
* screenshot the loading state — intermittently.
|
|
24
|
+
*/
|
|
25
|
+
const SETTLE_SCRIPT = `
|
|
26
|
+
var done = false;
|
|
27
|
+
window.__juxWait = new Promise(function (resolve) {
|
|
28
|
+
var pending = 0;
|
|
29
|
+
var idleTimer = null;
|
|
30
|
+
|
|
31
|
+
function finish() {
|
|
32
|
+
if (done) return;
|
|
33
|
+
done = true;
|
|
34
|
+
observer.disconnect();
|
|
35
|
+
clearTimeout(idleTimer);
|
|
36
|
+
clearTimeout(capTimer);
|
|
37
|
+
if (window.__juxFetchOrig) {
|
|
38
|
+
window.fetch = window.__juxFetchOrig;
|
|
39
|
+
delete window.__juxFetchOrig;
|
|
40
|
+
}
|
|
41
|
+
resolve();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var capTimer = setTimeout(finish, timeoutMs);
|
|
45
|
+
|
|
46
|
+
function scheduleIdle() {
|
|
47
|
+
clearTimeout(idleTimer);
|
|
48
|
+
if (pending === 0) idleTimer = setTimeout(finish, idleMs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!window.__juxFetchOrig) {
|
|
52
|
+
window.__juxFetchOrig = window.fetch;
|
|
53
|
+
window.fetch = function () {
|
|
54
|
+
pending++;
|
|
55
|
+
clearTimeout(idleTimer);
|
|
56
|
+
return window.__juxFetchOrig.apply(this, arguments).finally(function () {
|
|
57
|
+
pending--;
|
|
58
|
+
scheduleIdle();
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var observer = new MutationObserver(scheduleIdle);
|
|
64
|
+
observer.observe(document.documentElement, {
|
|
65
|
+
childList: true, subtree: true, attributes: true, characterData: true,
|
|
66
|
+
});
|
|
67
|
+
scheduleIdle();
|
|
68
|
+
});
|
|
69
|
+
`;
|
|
70
|
+
/** Arm the quiescence observer. Call before triggering a story render. */
|
|
71
|
+
export async function armSettle(page) {
|
|
72
|
+
await page
|
|
73
|
+
.evaluate(`(function (idleMs, timeoutMs) { ${SETTLE_SCRIPT} })(${DOM_IDLE_MS}, ${DOM_IDLE_TIMEOUT_MS})`)
|
|
74
|
+
.catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Await quiescence, webfonts and image decode in a single round trip.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately one `evaluate` rather than three. At ~1.2s per story and 8
|
|
80
|
+
* parallel pages, CDP round-trips are a measurable share of wall clock; the
|
|
81
|
+
* original tool cut per-story time meaningfully by collapsing these.
|
|
82
|
+
*/
|
|
83
|
+
export async function drain(page) {
|
|
84
|
+
await page
|
|
85
|
+
.evaluate(async () => {
|
|
86
|
+
const w = window;
|
|
87
|
+
await (w.__juxWait ?? Promise.resolve());
|
|
88
|
+
// Webfonts: a story captured mid-swap diffs against one captured after,
|
|
89
|
+
// for every glyph on the page.
|
|
90
|
+
await document.fonts?.ready?.catch?.(() => { });
|
|
91
|
+
// Decode, not just load — an undecoded image can still paint late.
|
|
92
|
+
await Promise.all(Array.from(document.images).map((img) => img.complete
|
|
93
|
+
? Promise.resolve()
|
|
94
|
+
: new Promise((r) => {
|
|
95
|
+
img.addEventListener('load', () => r(), { once: true });
|
|
96
|
+
img.addEventListener('error', () => r(), { once: true });
|
|
97
|
+
})));
|
|
98
|
+
})
|
|
99
|
+
.catch(() => { });
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Zero out animations and transitions.
|
|
103
|
+
*
|
|
104
|
+
* Without this, anything mid-animation at screenshot time — spinners, skeleton
|
|
105
|
+
* shimmer, fade-ins, the text caret — makes the story diff against itself on
|
|
106
|
+
* every run. Stories that exist specifically to test animation opt out with
|
|
107
|
+
* `animations: 'play'`.
|
|
108
|
+
*/
|
|
109
|
+
export const FREEZE_ANIMATIONS_CSS = `
|
|
110
|
+
*, *::before, *::after {
|
|
111
|
+
animation-duration: 0s !important;
|
|
112
|
+
animation-delay: 0s !important;
|
|
113
|
+
animation-iteration-count: 1 !important;
|
|
114
|
+
transition-duration: 0s !important;
|
|
115
|
+
transition-delay: 0s !important;
|
|
116
|
+
caret-color: transparent !important;
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
/** Wait for an explicit selector. Escape hatch for async content the network
|
|
120
|
+
* drain can't observe — SDK init, canvas paint, third-party widgets. */
|
|
121
|
+
export async function waitForSelector(page, selector, timeoutMs) {
|
|
122
|
+
await page.waitForSelector(selector, { timeout: timeoutMs }).catch(() => { });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Block until the compositor has actually presented a frame.
|
|
126
|
+
*
|
|
127
|
+
* The DOM going quiet is not the same as the pixels being final: style
|
|
128
|
+
* recalculation, layout and paint still have to happen, and a screenshot taken
|
|
129
|
+
* in that window catches an intermediate frame.
|
|
130
|
+
*
|
|
131
|
+
* This is not theoretical. Without it, repeated captures of an unchanged story
|
|
132
|
+
* differed by ~12 pixels out of 480,000 — each off by exactly 1 — on the
|
|
133
|
+
* antialiased edges of a rounded corner. Invisible to a human; a failed build
|
|
134
|
+
* under a byte-exact hash. Measured: flaky with no wait, stable from ~50ms,
|
|
135
|
+
* and stable with this barrier and no arbitrary delay at all.
|
|
136
|
+
*
|
|
137
|
+
* Two nested rAFs, then `requestIdleCallback`: the first rAF runs before the
|
|
138
|
+
* next paint, the second after it has been committed, and the idle callback
|
|
139
|
+
* gives any post-paint work a chance to drain. Cheap — a couple of frames at
|
|
140
|
+
* most — and it removes the need for a blanket sleep on every story.
|
|
141
|
+
*/
|
|
142
|
+
export async function waitForPaint(page, timeoutMs = 1000) {
|
|
143
|
+
await page
|
|
144
|
+
.evaluate((cap) => new Promise((resolve) => {
|
|
145
|
+
const done = () => resolve();
|
|
146
|
+
const timer = setTimeout(done, cap);
|
|
147
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
148
|
+
const finish = () => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
done();
|
|
151
|
+
};
|
|
152
|
+
if (typeof requestIdleCallback === 'function') {
|
|
153
|
+
requestIdleCallback(finish, { timeout: 100 });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
finish();
|
|
157
|
+
}
|
|
158
|
+
}));
|
|
159
|
+
}), timeoutMs)
|
|
160
|
+
.catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=settle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settle.js","sourceRoot":"","sources":["../../src/capture/settle.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,+EAA+E;AAC/E,kDAAkD;AAClD,EAAE;AACF,6CAA6C;AAC7C,2EAA2E;AAC3E,4EAA4E;AAC5E,+CAA+C;AAI/C;gCACgC;AAChC,MAAM,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC;AAC/B;;;+CAG+C;AAC/C,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAEvC;;;;;;;GAOG;AACH,MAAM,aAAa,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CrB,CAAC;AAEF,0EAA0E;AAC1E,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAU;IACxC,MAAM,IAAI;SACP,QAAQ,CACP,mCAAmC,aAAa,OAAO,WAAW,KAAK,mBAAmB,GAAG,CAC9F;SACA,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,IAAU;IACpC,MAAM,IAAI;SACP,QAAQ,CAAC,KAAK,IAAI,EAAE;QACnB,MAAM,CAAC,GAAG,MAAkD,CAAC;QAE7D,MAAM,CAAC,CAAC,CAAC,SAAS,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;QAEzC,wEAAwE;QACxE,+BAA+B;QAC/B,MAAM,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAE/C,mEAAmE;QACnE,MAAM,OAAO,CAAC,GAAG,CACf,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CACtC,GAAG,CAAC,QAAQ;YACV,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE;YACnB,CAAC,CAAC,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,EAAE;gBACtB,GAAG,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;gBACxD,GAAG,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3D,CAAC,CAAC,CACP,CACF,CAAC;IACJ,CAAC,CAAC;SACD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;CASpC,CAAC;AAEF;yEACyE;AACzE,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,IAAU,EACV,QAAgB,EAChB,SAAiB;IAEjB,MAAM,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAU,EAAE,SAAS,GAAG,IAAI;IAC7D,MAAM,IAAI;SACP,QAAQ,CACP,CAAC,GAAG,EAAE,EAAE,CACN,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QAC5B,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC;QAC7B,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QACpC,qBAAqB,CAAC,GAAG,EAAE,CACzB,qBAAqB,CAAC,GAAG,EAAE;YACzB,MAAM,MAAM,GAAG,GAAG,EAAE;gBAClB,YAAY,CAAC,KAAK,CAAC,CAAC;gBACpB,IAAI,EAAE,CAAC;YACT,CAAC,CAAC;YACF,IAAI,OAAO,mBAAmB,KAAK,UAAU,EAAE,CAAC;gBAC9C,mBAAmB,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;YAChD,CAAC;iBAAM,CAAC;gBACN,MAAM,EAAE,CAAC;YACX,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC,CAAC,EACJ,SAAS,CACV;SACA,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AACrB,CAAC"}
|