@qoretechnologies/qlip 0.1.3-pr.29.g497a7aa → 0.1.3-pr.30.gc7b05dc

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/README.md CHANGED
@@ -196,6 +196,8 @@ export const LoggedIn = {
196
196
  viewport: { width: 1280, height: 720 },
197
197
  disableAnimations: true,
198
198
  pauseAnimationsAtEnd: false,
199
+ fullPage: false,
200
+ fullPageMaxHeight: 10000,
199
201
  captureOnError: false,
200
202
  waitForIdleMs: 300,
201
203
  maxWaitForIdleMs: 2000,
@@ -219,6 +221,8 @@ qlipVitestPlugin({
219
221
  viewport: { width: 1280, height: 720 },
220
222
  disableAnimations: false,
221
223
  pauseAnimationsAtEnd: false,
224
+ fullPage: false,
225
+ fullPageMaxHeight: 10000,
222
226
  captureOnError: false,
223
227
  waitForIdleMs: 300,
224
228
  maxWaitForIdleMs: 2000,
@@ -227,6 +231,8 @@ qlipVitestPlugin({
227
231
  });
228
232
  ```
229
233
 
234
+ `fullPage` grows the viewport to the page's full content height for the capture and puts it back afterwards. A story that pins a phone viewport is otherwise photographed one screen tall; a browser "full page" screenshot would not help, because an app shell usually scrolls an inner box rather than the document. qlip measures the document and every scrolling element, grows the viewport by what they hide, and re-measures after each growth until nothing scrolls. Two kinds of box are not chased. A box that keeps its height when the viewport grows (a fixed-height code block, a capped list) stays scrolled, and the growth it asked for is given back, so the capture is not padded with blank space. Content that grows with the viewport (a tile sized to its column, a `vh` hero) would ask for more every round; it stops at the height its real content asked for. The height is capped at `fullPageMaxHeight` (default 10000); a page taller than that is captured at the cap with a `[qlip] fullPage:` warning naming the story. Under Vitest browser mode the test iframe is scaled down to fit the browser window, so the plugin also grows the window — the Playwright context viewport — through a browser command it registers (`qlipBrowserViewport`), and restores it first; the capture is 1:1, not a shrunk picture of a tall page. The same holds for any pinned `viewport` the window cannot hold: an 844px-tall phone story in a 720px window used to come back at 85%, and now grows the window too. The test-runner path grows its Playwright viewport the same way. The manifest keeps reporting the pinned `viewport` — that is the baseline key; the image is simply taller, and a capture whose content height changed shows on the server as a dimensions change rather than a pixel diff.
235
+
230
236
  `waitForIdleMs` waits for DOM mutations to settle before taking a screenshot. This is especially useful for animation libraries like `react-spring` that update inline styles via `requestAnimationFrame`, which bypasses CSS-based animation disabling. Increase it if you still catch mid-transition frames, or lower it for faster runs when your UI is static. `maxWaitForIdleMs` caps the wait so stories with continuously changing UI still complete.
231
237
 
232
238
  `ignoreElements` lets you provide CSS selectors to mask before capture. Qlip draws solid overlays on matching elements so layout stays intact while visual diffs ignore those regions.
@@ -8,6 +8,8 @@ export interface QlipResolvedOptions {
8
8
  disableAnimations: boolean;
9
9
  pauseAnimationsAtEnd: boolean;
10
10
  disableBackdropFilter: boolean;
11
+ fullPage: boolean;
12
+ fullPageMaxHeight: number;
11
13
  waitForIdleMs: number;
12
14
  maxWaitForIdleMs: number;
13
15
  ignoreElements: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"parameters.d.ts","sourceRoot":"","sources":["../../src/config/parameters.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAE5G,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;IACxB,oBAAoB,EAAE,gBAAgB,EAAE,CAAC;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,yBAAyB,EAAE,MAAM,EAAE,CAAC;CACrC;AAED,eAAO,MAAM,kBAAkB,GAAI,gCAIhC;IACD,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAClC,KAAG,mBAgDH,CAAC"}
1
+ {"version":3,"file":"parameters.d.ts","sourceRoot":"","sources":["../../src/config/parameters.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAE5G,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,QAAQ,EAAE,OAAO,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;IACxB,oBAAoB,EAAE,gBAAgB,EAAE,CAAC;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,yBAAyB,EAAE,MAAM,EAAE,CAAC;CACrC;AAED,eAAO,MAAM,kBAAkB,GAAI,gCAIhC;IACD,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAClC,KAAG,mBAqDH,CAAC"}
@@ -11,6 +11,10 @@ export const resolveQlipOptions = ({ defaults, story, override, }) => {
11
11
  disableBackdropFilter: override?.disableBackdropFilter ??
12
12
  story?.disableBackdropFilter ??
13
13
  defaults.disableBackdropFilter,
14
+ fullPage: override?.fullPage ?? story?.fullPage ?? defaults.fullPage,
15
+ fullPageMaxHeight: override?.fullPageMaxHeight ??
16
+ story?.fullPageMaxHeight ??
17
+ defaults.fullPageMaxHeight,
14
18
  waitForIdleMs: override?.waitForIdleMs ??
15
19
  story?.waitForIdleMs ??
16
20
  defaults.waitForIdleMs,
@@ -0,0 +1,21 @@
1
+ import type { BrowserCommand } from 'vitest/node';
2
+ import type { FullPageSize } from '../runtime/fullPage.js';
3
+ /** The name the runtime calls the command by (`commands.qlipBrowserViewport`). */
4
+ export declare const QLIP_BROWSER_VIEWPORT_COMMAND = "qlipBrowserViewport";
5
+ /**
6
+ * Reads — and, given a size, sets — the browser window's viewport: the
7
+ * Playwright context viewport that Vitest scales the test iframe to fit into.
8
+ * A capture whose viewport is taller or wider than the window comes back
9
+ * shrunk to fit unless the window grows with it (a 844px phone story in a
10
+ * 720px window is an 85% picture of it; a 3,600px full-page capture in a
11
+ * 1,440px window, a 40% one). Runs in the Vitest server process where the
12
+ * Playwright page lives; the runtime reaches it through
13
+ * `commands.qlipBrowserViewport`.
14
+ *
15
+ * Answers the size the window had before the call, or `null` when there is
16
+ * no such viewport to grow: another provider (webdriverio resizes the real
17
+ * window from `page.viewport()` and scales nothing), or a context launched
18
+ * with `viewport: null`.
19
+ */
20
+ export declare const qlipBrowserViewport: BrowserCommand<[size?: FullPageSize | null]>;
21
+ //# sourceMappingURL=browserViewport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browserViewport.d.ts","sourceRoot":"","sources":["../../src/plugin/browserViewport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,wBAAwB,CAAC;AAQnE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,EAAE,cAAc,CAAC,CAAC,IAAI,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC,CAa5E,CAAC"}
@@ -0,0 +1,28 @@
1
+ /** The name the runtime calls the command by (`commands.qlipBrowserViewport`). */
2
+ export const QLIP_BROWSER_VIEWPORT_COMMAND = 'qlipBrowserViewport';
3
+ /**
4
+ * Reads — and, given a size, sets — the browser window's viewport: the
5
+ * Playwright context viewport that Vitest scales the test iframe to fit into.
6
+ * A capture whose viewport is taller or wider than the window comes back
7
+ * shrunk to fit unless the window grows with it (a 844px phone story in a
8
+ * 720px window is an 85% picture of it; a 3,600px full-page capture in a
9
+ * 1,440px window, a 40% one). Runs in the Vitest server process where the
10
+ * Playwright page lives; the runtime reaches it through
11
+ * `commands.qlipBrowserViewport`.
12
+ *
13
+ * Answers the size the window had before the call, or `null` when there is
14
+ * no such viewport to grow: another provider (webdriverio resizes the real
15
+ * window from `page.viewport()` and scales nothing), or a context launched
16
+ * with `viewport: null`.
17
+ */
18
+ export const qlipBrowserViewport = async (context, size) => {
19
+ const page = context.page;
20
+ if (context.provider.name !== 'playwright' || !page) {
21
+ return null;
22
+ }
23
+ const current = page.viewportSize();
24
+ if (size && current && (size.width !== current.width || size.height !== current.height)) {
25
+ await page.setViewportSize(size);
26
+ }
27
+ return current;
28
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"vitestPlugin.d.ts","sourceRoot":"","sources":["../../src/plugin/vitestPlugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAU5C,OAAO,EAAE,iBAAiB,EAAqB,MAAM,aAAa,CAAC;AAMnE,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MAmQlE,CAAC"}
1
+ {"version":3,"file":"vitestPlugin.d.ts","sourceRoot":"","sources":["../../src/plugin/vitestPlugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAU5C,OAAO,EAAE,iBAAiB,EAAqB,MAAM,aAAa,CAAC;AAanE,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MA8QlE,CAAC"}
@@ -6,6 +6,9 @@ import pkg from '../../package.json' with { type: 'json' };
6
6
  import { DEFAULT_OUTPUT_DIR, DEFAULT_VIEWPORT, MANIFEST_FRAGMENT_DIR, TOOL_NAME, generateBuildId, } from '../fs/output.js';
7
7
  import { QlipUploadReporter } from '../upload/reporter.js';
8
8
  import { stashFinalizeConfig } from '../runtime/global-setup.js';
9
+ import { QLIP_BROWSER_VIEWPORT_COMMAND, qlipBrowserViewport, } from './browserViewport.js';
10
+ /** See `QlipCaptureOptions.fullPageMaxHeight`. */
11
+ const DEFAULT_FULL_PAGE_MAX_HEIGHT = 10000;
9
12
  const normalizePath = (value) => value.replace(/\\/g, '/');
10
13
  export const qlipVitestPlugin = (options = {}) => {
11
14
  const buildId = options.buildId ?? generateBuildId();
@@ -37,6 +40,8 @@ export const qlipVitestPlugin = (options = {}) => {
37
40
  // never a wanted baseline. See QlipCaptureOptions.
38
41
  disableBackdropFilter: options.disableBackdropFilter ?? true,
39
42
  captureOnError: options.captureOnError ?? false,
43
+ fullPage: options.fullPage ?? false,
44
+ fullPageMaxHeight: options.fullPageMaxHeight ?? DEFAULT_FULL_PAGE_MAX_HEIGHT,
40
45
  waitForIdleMs: options.waitForIdleMs ?? 300,
41
46
  maxWaitForIdleMs: options.maxWaitForIdleMs ?? 2000,
42
47
  ignoreElements: options.ignoreElements ?? [],
@@ -185,6 +190,15 @@ export const qlipVitestPlugin = (options = {}) => {
185
190
  setupFiles: [setupFile],
186
191
  globalSetup: [globalSetupFile],
187
192
  reporters: reportersList,
193
+ // The runtime grows the browser window for a `fullPage` capture
194
+ // through this command; it runs here, where the Playwright page
195
+ // is. `mergeConfig` merges it beside any commands the consumer
196
+ // declares.
197
+ browser: {
198
+ commands: {
199
+ [QLIP_BROWSER_VIEWPORT_COMMAND]: qlipBrowserViewport,
200
+ },
201
+ },
188
202
  },
189
203
  };
190
204
  },
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A `fullPage` capture grows the viewport until nothing on the page scrolls,
3
+ * photographs it, and puts the viewport back. A story that pins a phone
4
+ * viewport is otherwise one screen tall, and a browser "full page" screenshot
5
+ * would not help: an app shell pins the document to the viewport and scrolls
6
+ * an inner box, so the document is never taller than one screen.
7
+ *
8
+ * Under Vitest browser mode two viewports are involved. `page.viewport()`
9
+ * sizes the test iframe; the real browser window (the Playwright context
10
+ * viewport) stays as configured, and Vitest scales the iframe down to fit it.
11
+ * A 3,600px-tall iframe in a 1,440px window is photographed at 40% — the
12
+ * whole page, unreadable — and so is a plain 844px-tall phone story in a
13
+ * 720px window, at 85%. `createCaptureViewport` therefore grows the window
14
+ * to hold whatever viewport a capture asks for, pinned or grown, and puts
15
+ * both back afterwards, the window first, so the iframe lands at scale 1.
16
+ *
17
+ * This module has no imports: the runtime, the test-runner and the unit
18
+ * project (which has no DOM and feeds it fakes) all share it.
19
+ */
20
+ export interface FullPageSize {
21
+ width: number;
22
+ height: number;
23
+ }
24
+ /**
25
+ * How tall the page needs the viewport to be for nothing on it to scroll:
26
+ * the document, and every box that scrolls its own content. A scrolling box
27
+ * asks for the viewport plus whatever it hides — a box that fills the
28
+ * viewport grows with it one for one, whatever sits above or below it — and
29
+ * a box that already fits asks for the viewport minus its spare room.
30
+ * Answers 0 when nothing scrolls.
31
+ *
32
+ * A box only counts while growing the viewport can still reveal it, which
33
+ * only a re-measure after a growth can tell, so the rounds of one capture
34
+ * share a memory (`reset` starts a new one):
35
+ * - A box that kept its height when the viewport grew has a height of its
36
+ * own (a 200px code block, a capped list). More viewport never reveals its
37
+ * content, so it stops counting, and growth it asked for is given back.
38
+ * - A box whose content grew as much as the box did is sized to it (a tile
39
+ * that follows its column). Chasing it only stretches the tile, so it keeps
40
+ * asking for what it asked before it scaled. The document gets the same
41
+ * test (a `vh` hero).
42
+ * - A box whose hidden content shrank was revealed and keeps counting, even
43
+ * when a growth reveals less than it added.
44
+ *
45
+ * The document's own `scrollHeight` is the viewport again once the viewport
46
+ * outgrows it, so its tallest height while it still scrolled is remembered.
47
+ *
48
+ * Closure-free, so the test-runner can hand it to Playwright's
49
+ * `page.evaluate()`, which re-evaluates the function's source inside the
50
+ * page; there it reads the real `document` and `window`. `env` exists for
51
+ * the unit test, which has neither.
52
+ */
53
+ export declare const measureContentHeight: (options?: {
54
+ reset?: boolean;
55
+ env?: {
56
+ doc: Pick<Document, "documentElement" | "body" | "querySelectorAll">;
57
+ win: Pick<Window, "getComputedStyle" | "innerHeight">;
58
+ };
59
+ }) => number;
60
+ /** Where `growViewportToContent` left the viewport, and why. */
61
+ export interface FullPageGrowth {
62
+ /** The height the viewport ended at: the pinned height when nothing grew. */
63
+ height: number;
64
+ /** What the content last asked for, before the `maxHeight` cap. */
65
+ wanted: number;
66
+ }
67
+ /**
68
+ * Grows the viewport until nothing on the page scrolls. Growing the viewport
69
+ * re-lays the page out — a scrolling box that was 700px tall becomes 3,500px
70
+ * and may reveal another box below it that scrolls in turn — so the height is
71
+ * measured again after every growth, until a round asks for nothing more or
72
+ * `maxRounds` is spent. Content that does not grow with the viewport, or
73
+ * grows with it, is `measure`'s call (see `measureContentHeight`); when a
74
+ * round asks for less than the viewport already is, that growth went to such
75
+ * a box, and it is given back.
76
+ *
77
+ * `measure` is told when it is the capture's first round, so it can start a
78
+ * new memory.
79
+ */
80
+ export declare const growViewportToContent: ({ viewport, maxHeight, setViewport, measure, settle, maxRounds, }: {
81
+ viewport: FullPageSize;
82
+ maxHeight: number;
83
+ setViewport: (width: number, height: number) => Promise<void>;
84
+ measure: (first: boolean) => number | Promise<number>;
85
+ settle: () => Promise<void>;
86
+ maxRounds?: number;
87
+ }) => Promise<FullPageGrowth>;
88
+ /**
89
+ * The warning for a `fullPage` capture the height cap cut short, or `null`:
90
+ * a page taller than `fullPageMaxHeight` is captured at the cap, and a cut-off
91
+ * page must not pass for the whole one.
92
+ */
93
+ export declare const fullPageCapWarning: (storyId: string, growth: FullPageGrowth, maxHeight: number) => string | null;
94
+ /**
95
+ * The browser-window half of the viewport, as the qlip plugin's browser
96
+ * command exposes it: called without a size it answers the window's current
97
+ * viewport, with one it resizes the window and answers the previous size.
98
+ * `null` means the provider has no window viewport to grow — webdriverio
99
+ * resizes the real window from `page.viewport()` itself and scales nothing.
100
+ */
101
+ export type BrowserViewportCommand = (size?: FullPageSize | null) => Promise<FullPageSize | null>;
102
+ /**
103
+ * The two viewports a capture sizes, and how each is put back. `set` grows
104
+ * the window to hold the iframe whenever the iframe would not fit (never
105
+ * shrinking it below what it was), then sizes the iframe; the window is
106
+ * asked for its size once. `restore` puts the window back first, so the
107
+ * iframe's pinned size is computed against the original window again, then
108
+ * the iframe — and touches neither when neither moved.
109
+ */
110
+ export declare const createCaptureViewport: ({ pinned, iframe, window: windowViewport, }: {
111
+ pinned: FullPageSize;
112
+ iframe: (width: number, height: number) => Promise<void>;
113
+ window?: BrowserViewportCommand | undefined;
114
+ }) => {
115
+ set: (width: number, height: number) => Promise<void>;
116
+ restore: () => Promise<void>;
117
+ };
118
+ //# sourceMappingURL=fullPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fullPage.d.ts","sourceRoot":"","sources":["../../src/runtime/fullPage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,oBAAoB,GAAI,UAAU;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE;QACJ,GAAG,EAAE,IAAI,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,GAAG,kBAAkB,CAAC,CAAC;QACrE,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,kBAAkB,GAAG,aAAa,CAAC,CAAC;KACvD,CAAC;CACH,KAAG,MAsEH,CAAC;AAEF,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAU,mEAOzC;IACD,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,OAAO,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,KAAG,OAAO,CAAC,cAAc,CAmBzB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,EACf,QAAQ,cAAc,EACtB,WAAW,MAAM,KAChB,MAAM,GAAG,IAKF,CAAC;AAEX;;;;;;GAMG;AACH,MAAM,MAAM,sBAAsB,GAAG,CACnC,IAAI,CAAC,EAAE,YAAY,GAAG,IAAI,KACvB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;AAElC;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,6CAInC;IACD,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,MAAM,CAAC,EAAE,sBAAsB,GAAG,SAAS,CAAC;CAC7C,KAAG;IACF,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CA4C9B,CAAC"}
@@ -0,0 +1,214 @@
1
+ /**
2
+ * A `fullPage` capture grows the viewport until nothing on the page scrolls,
3
+ * photographs it, and puts the viewport back. A story that pins a phone
4
+ * viewport is otherwise one screen tall, and a browser "full page" screenshot
5
+ * would not help: an app shell pins the document to the viewport and scrolls
6
+ * an inner box, so the document is never taller than one screen.
7
+ *
8
+ * Under Vitest browser mode two viewports are involved. `page.viewport()`
9
+ * sizes the test iframe; the real browser window (the Playwright context
10
+ * viewport) stays as configured, and Vitest scales the iframe down to fit it.
11
+ * A 3,600px-tall iframe in a 1,440px window is photographed at 40% — the
12
+ * whole page, unreadable — and so is a plain 844px-tall phone story in a
13
+ * 720px window, at 85%. `createCaptureViewport` therefore grows the window
14
+ * to hold whatever viewport a capture asks for, pinned or grown, and puts
15
+ * both back afterwards, the window first, so the iframe lands at scale 1.
16
+ *
17
+ * This module has no imports: the runtime, the test-runner and the unit
18
+ * project (which has no DOM and feeds it fakes) all share it.
19
+ */
20
+ /**
21
+ * How tall the page needs the viewport to be for nothing on it to scroll:
22
+ * the document, and every box that scrolls its own content. A scrolling box
23
+ * asks for the viewport plus whatever it hides — a box that fills the
24
+ * viewport grows with it one for one, whatever sits above or below it — and
25
+ * a box that already fits asks for the viewport minus its spare room.
26
+ * Answers 0 when nothing scrolls.
27
+ *
28
+ * A box only counts while growing the viewport can still reveal it, which
29
+ * only a re-measure after a growth can tell, so the rounds of one capture
30
+ * share a memory (`reset` starts a new one):
31
+ * - A box that kept its height when the viewport grew has a height of its
32
+ * own (a 200px code block, a capped list). More viewport never reveals its
33
+ * content, so it stops counting, and growth it asked for is given back.
34
+ * - A box whose content grew as much as the box did is sized to it (a tile
35
+ * that follows its column). Chasing it only stretches the tile, so it keeps
36
+ * asking for what it asked before it scaled. The document gets the same
37
+ * test (a `vh` hero).
38
+ * - A box whose hidden content shrank was revealed and keeps counting, even
39
+ * when a growth reveals less than it added.
40
+ *
41
+ * The document's own `scrollHeight` is the viewport again once the viewport
42
+ * outgrows it, so its tallest height while it still scrolled is remembered.
43
+ *
44
+ * Closure-free, so the test-runner can hand it to Playwright's
45
+ * `page.evaluate()`, which re-evaluates the function's source inside the
46
+ * page; there it reads the real `document` and `window`. `env` exists for
47
+ * the unit test, which has neither.
48
+ */
49
+ export const measureContentHeight = (options) => {
50
+ const doc = options?.env?.doc ?? document;
51
+ const win = options?.env?.win ?? window;
52
+ const holder = win;
53
+ if (options?.reset || !holder.__qlipFullPage) {
54
+ holder.__qlipFullPage = {
55
+ viewport: 0,
56
+ docHeight: 0,
57
+ docOverflow: 0,
58
+ docScales: false,
59
+ boxes: new WeakMap(),
60
+ fixed: new WeakSet(),
61
+ scales: new WeakSet(),
62
+ };
63
+ }
64
+ const memory = holder.__qlipFullPage;
65
+ const viewport = win.innerHeight;
66
+ const grew = memory.viewport > 0 && viewport > memory.viewport + 1;
67
+ const docScroll = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
68
+ const docOverflow = Math.max(0, docScroll - viewport);
69
+ if (grew && memory.docOverflow > 0 && docOverflow >= memory.docOverflow - 1) {
70
+ memory.docScales = true;
71
+ }
72
+ memory.docOverflow = docOverflow;
73
+ if (docOverflow > 1 && !memory.docScales) {
74
+ memory.docHeight = Math.max(memory.docHeight, docScroll);
75
+ }
76
+ let height = memory.docHeight;
77
+ for (const el of Array.from(doc.querySelectorAll('*'))) {
78
+ const seen = memory.boxes.get(el);
79
+ if (!seen) {
80
+ if (el.scrollHeight <= el.clientHeight + 1) {
81
+ continue;
82
+ }
83
+ const overflowY = win.getComputedStyle(el).overflowY;
84
+ if (overflowY !== 'auto' && overflowY !== 'scroll') {
85
+ continue;
86
+ }
87
+ }
88
+ const overflow = Math.max(0, el.scrollHeight - el.clientHeight);
89
+ if (seen && grew) {
90
+ if (el.clientHeight <= seen.clientHeight + 1) {
91
+ memory.fixed.add(el);
92
+ }
93
+ else if (seen.overflow > 0 && overflow >= seen.overflow - 1) {
94
+ memory.scales.add(el);
95
+ }
96
+ }
97
+ if (memory.fixed.has(el)) {
98
+ continue;
99
+ }
100
+ // A box that fits reports its own height as scrollHeight, so its content
101
+ // height is the one it had while it still scrolled.
102
+ const content = overflow > 1 ? Math.max(seen?.content ?? 0, el.scrollHeight) : (seen?.content ?? el.scrollHeight);
103
+ let need;
104
+ if (seen && memory.scales.has(el)) {
105
+ need = seen.need;
106
+ }
107
+ else if (overflow > 1) {
108
+ need = viewport + overflow;
109
+ }
110
+ else {
111
+ need = viewport - Math.max(0, el.clientHeight - content);
112
+ }
113
+ need = Math.ceil(need);
114
+ memory.boxes.set(el, { clientHeight: el.clientHeight, overflow, content, need });
115
+ height = Math.max(height, need);
116
+ }
117
+ memory.viewport = viewport;
118
+ return height;
119
+ };
120
+ /**
121
+ * Grows the viewport until nothing on the page scrolls. Growing the viewport
122
+ * re-lays the page out — a scrolling box that was 700px tall becomes 3,500px
123
+ * and may reveal another box below it that scrolls in turn — so the height is
124
+ * measured again after every growth, until a round asks for nothing more or
125
+ * `maxRounds` is spent. Content that does not grow with the viewport, or
126
+ * grows with it, is `measure`'s call (see `measureContentHeight`); when a
127
+ * round asks for less than the viewport already is, that growth went to such
128
+ * a box, and it is given back.
129
+ *
130
+ * `measure` is told when it is the capture's first round, so it can start a
131
+ * new memory.
132
+ */
133
+ export const growViewportToContent = async ({ viewport, maxHeight, setViewport, measure, settle, maxRounds = 4, }) => {
134
+ let height = viewport.height;
135
+ let wanted = 0;
136
+ for (let round = 0; round < maxRounds; round += 1) {
137
+ wanted = Math.ceil(await measure(round === 0));
138
+ const asked = Math.min(Math.max(wanted, viewport.height), maxHeight);
139
+ if (asked === height) {
140
+ break;
141
+ }
142
+ const givingBack = asked < height;
143
+ height = asked;
144
+ await setViewport(viewport.width, height);
145
+ await settle();
146
+ if (givingBack) {
147
+ // Every box still counted asked for no more than this, so it fits.
148
+ break;
149
+ }
150
+ }
151
+ return { height, wanted };
152
+ };
153
+ /**
154
+ * The warning for a `fullPage` capture the height cap cut short, or `null`:
155
+ * a page taller than `fullPageMaxHeight` is captured at the cap, and a cut-off
156
+ * page must not pass for the whole one.
157
+ */
158
+ export const fullPageCapWarning = (storyId, growth, maxHeight) => growth.wanted > maxHeight
159
+ ? `[qlip] fullPage: ${storyId} is ${String(growth.wanted)}px tall but was captured at ` +
160
+ `the ${String(maxHeight)}px cap; raise fullPageMaxHeight (Chromium allows up to 16384) ` +
161
+ 'to capture all of it.'
162
+ : null;
163
+ /**
164
+ * The two viewports a capture sizes, and how each is put back. `set` grows
165
+ * the window to hold the iframe whenever the iframe would not fit (never
166
+ * shrinking it below what it was), then sizes the iframe; the window is
167
+ * asked for its size once. `restore` puts the window back first, so the
168
+ * iframe's pinned size is computed against the original window again, then
169
+ * the iframe — and touches neither when neither moved.
170
+ */
171
+ export const createCaptureViewport = ({ pinned, iframe, window: windowViewport, }) => {
172
+ // undefined: not asked yet; null: asked, and there is no window to grow
173
+ let base;
174
+ let windowAt = null;
175
+ let iframeAt = null;
176
+ const same = (a, b) => a.width === b.width && a.height === b.height;
177
+ const growWindow = async (width, height) => {
178
+ if (!windowViewport) {
179
+ return;
180
+ }
181
+ if (base === undefined) {
182
+ base = await windowViewport();
183
+ }
184
+ if (!base) {
185
+ return;
186
+ }
187
+ const target = {
188
+ width: Math.max(base.width, width),
189
+ height: Math.max(base.height, height),
190
+ };
191
+ if (same(target, windowAt ?? base)) {
192
+ return;
193
+ }
194
+ await windowViewport(target);
195
+ windowAt = target;
196
+ };
197
+ return {
198
+ set: async (width, height) => {
199
+ await growWindow(width, height);
200
+ await iframe(width, height);
201
+ iframeAt = { width, height };
202
+ },
203
+ restore: async () => {
204
+ if (base && windowAt && windowViewport) {
205
+ await windowViewport(base);
206
+ windowAt = null;
207
+ }
208
+ if (iframeAt && !same(iframeAt, pinned)) {
209
+ await iframe(pinned.width, pinned.height);
210
+ iframeAt = pinned;
211
+ }
212
+ },
213
+ };
214
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../src/runtime/screenshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,qBAAqB,EACrB,gBAAgB,EAEjB,MAAM,aAAa,CAAC;AA8BrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AA8L1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,MAAM;AACf;;;;;;;GAOG;AACH,gBAAgB,MAAM,KACrB;IAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CAiCvD,CAAC;AAwDF;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GAAI,qEAIxC;IACD,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;CAChC,SA+DA,CAAC;AA+ZF,eAAO,MAAM,UAAU,GACrB,KAAK,gBAAgB,EACrB,gBAAgB,MAAM,GAAG,qBAAqB,EAC9C,UAAU,qBAAqB,kBA0BhC,CAAC;AAEF,KAAK,eAAe,GAAG,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAC;AA8ClE,eAAO,MAAM,qBAAqB,GAAU,KAAK,eAAe,kBAiG/D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAU,KAAK,eAAe,kBAkEhE,CAAC"}
1
+ {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../src/runtime/screenshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,qBAAqB,EACrB,gBAAgB,EAEjB,MAAM,aAAa,CAAC;AAqCrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AA8L1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,MAAM;AACf;;;;;;;GAOG;AACH,gBAAgB,MAAM,KACrB;IAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CAiCvD,CAAC;AAwDF;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GAAI,qEAIxC;IACD,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;CAChC,SA+DA,CAAC;AA4bF,eAAO,MAAM,UAAU,GACrB,KAAK,gBAAgB,EACrB,gBAAgB,MAAM,GAAG,qBAAqB,EAC9C,UAAU,qBAAqB,kBA0BhC,CAAC;AAEF,KAAK,eAAe,GAAG,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAC;AA8ClE,eAAO,MAAM,qBAAqB,GAAU,KAAK,eAAe,kBAiG/D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAU,KAAK,eAAe,kBAkEhE,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import { resolveQlipOptions } from '../config/parameters.js';
2
+ import { createCaptureViewport, fullPageCapWarning, growViewportToContent, measureContentHeight, } from './fullPage.js';
2
3
  import { monotonicNow, realClearInterval, realSetInterval, realSetTimeout, } from './clock.js';
3
4
  import { AUTO_ERROR_SCREENSHOT_BASE, MANIFEST_FRAGMENT_DIR, buildAutoLogPath, buildAutoScreenshotPath, buildManualLogPath, buildManualScreenshotPath, fragmentFileName, joinPath, sanitizeSegment, } from '../fs/output.js';
4
5
  import { flushConsoleLogs, initRuntimeState, markStorybookWarning, nextFragmentSeq, nextStepName, pruneStaleErrorEntries, pushEntry, updateStats, } from './context.js';
@@ -490,14 +491,35 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
490
491
  // throws.
491
492
  let error = testError ?? null;
492
493
  let cleanupMasks = null;
494
+ // The browser window grows to hold whatever viewport the capture asks for,
495
+ // pinned or full-page, or the capture comes back scaled down to fit the
496
+ // window (see ./fullPage.ts).
497
+ const captureViewport = createCaptureViewport({
498
+ pinned: resolved.viewport,
499
+ iframe: (width, height) => page.viewport(width, height),
500
+ window: commands.qlipBrowserViewport,
501
+ });
493
502
  try {
494
- await page.viewport(resolved.viewport.width, resolved.viewport.height);
503
+ await captureViewport.set(resolved.viewport.width, resolved.viewport.height);
495
504
  applyCaptureStyleOverrides({
496
505
  disableAnimations: resolved.disableAnimations,
497
506
  pauseAnimationsAtEnd: resolved.pauseAnimationsAtEnd,
498
507
  disableBackdropFilter: resolved.disableBackdropFilter,
499
508
  });
500
509
  await waitForDomIdle(resolved.waitForIdleMs, resolved.maxWaitForIdleMs);
510
+ if (resolved.fullPage) {
511
+ const growth = await growViewportToContent({
512
+ viewport: resolved.viewport,
513
+ maxHeight: resolved.fullPageMaxHeight,
514
+ setViewport: captureViewport.set,
515
+ measure: (first) => measureContentHeight({ reset: first }),
516
+ settle: () => waitForDomIdle(resolved.waitForIdleMs, resolved.maxWaitForIdleMs),
517
+ });
518
+ const warning = fullPageCapWarning(story.id, growth, resolved.fullPageMaxHeight);
519
+ if (warning) {
520
+ console.warn(warning);
521
+ }
522
+ }
501
523
  cleanupMasks = applyIgnoreMasks(resolved.ignoreElements);
502
524
  await page.screenshot({ path: absolutePath, save: true });
503
525
  }
@@ -513,6 +535,15 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
513
535
  if (cleanupMasks) {
514
536
  cleanupMasks();
515
537
  }
538
+ // The pinned viewport is the baseline key and what the next capture
539
+ // inherits; a failure to put it back must not hide the capture's own
540
+ // outcome.
541
+ try {
542
+ await captureViewport.restore();
543
+ }
544
+ catch {
545
+ // The suite's own viewport reset runs before the next story.
546
+ }
516
547
  }
517
548
  const flushed = flushConsoleLogs(runtime);
518
549
  let logsPath;
@@ -0,0 +1,20 @@
1
+ export interface ITallPageProps {
2
+ /** Sections in the scrolling column; each is 240px tall. */
3
+ sections?: number;
4
+ }
5
+ /**
6
+ * An app shell the way real ones are built: the document is pinned to the
7
+ * viewport and never scrolls, a header stays put, and a column below it
8
+ * scrolls its own content. A one-screen capture of this page shows the
9
+ * header and the first sections; everything else is inside the column's
10
+ * scroll, where neither the document height nor a browser "full page"
11
+ * screenshot can see it.
12
+ */
13
+ export declare const TallPage: ({ sections }: ITallPageProps) => import("react/jsx-runtime").JSX.Element;
14
+ /**
15
+ * An ordinary page, nothing pinned, whose only scroller is a fixed-height
16
+ * box (a code block, a capped list). Growing the viewport never grows that
17
+ * box, so a `fullPage` capture has nothing to reveal and stays one screen.
18
+ */
19
+ export declare const FixedBoxPage: () => import("react/jsx-runtime").JSX.Element;
20
+ //# sourceMappingURL=TallPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TallPage.d.ts","sourceRoot":"","sources":["../../src/stories/TallPage.tsx"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,GAAI,cAAmB,cAAc,4CAiCzD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,+CAWxB,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * An app shell the way real ones are built: the document is pinned to the
4
+ * viewport and never scrolls, a header stays put, and a column below it
5
+ * scrolls its own content. A one-screen capture of this page shows the
6
+ * header and the first sections; everything else is inside the column's
7
+ * scroll, where neither the document height nor a browser "full page"
8
+ * screenshot can see it.
9
+ */
10
+ export const TallPage = ({ sections = 12 }) => (_jsxs("div", { style: { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [_jsx("header", { style: {
11
+ flex: '0 0 56px',
12
+ display: 'flex',
13
+ alignItems: 'center',
14
+ padding: '0 16px',
15
+ background: '#1d4ed8',
16
+ color: '#fff',
17
+ fontWeight: 600,
18
+ }, children: "Inbox" }), _jsx("main", { "data-testid": "tall-page-scroll", style: { flex: 1, minHeight: 0, overflowY: 'auto' }, children: Array.from({ length: sections }, (_, index) => (_jsxs("section", { style: {
19
+ height: 240,
20
+ boxSizing: 'border-box',
21
+ padding: 16,
22
+ borderBottom: '1px solid #e5e7eb',
23
+ background: index % 2 ? '#f9fafb' : '#fff',
24
+ }, children: [_jsxs("h2", { style: { margin: 0 }, children: ["Section ", index + 1] }), _jsxs("p", { children: ["Message ", index + 1, " of ", sections, "."] })] }, index))) })] }));
25
+ /**
26
+ * An ordinary page, nothing pinned, whose only scroller is a fixed-height
27
+ * box (a code block, a capped list). Growing the viewport never grows that
28
+ * box, so a `fullPage` capture has nothing to reveal and stays one screen.
29
+ */
30
+ export const FixedBoxPage = () => (_jsxs("div", { style: { padding: 16, fontFamily: 'sans-serif' }, children: [_jsx("h1", { style: { margin: 0 }, children: "Release notes" }), _jsx("pre", { "data-testid": "fixed-box", style: { height: 200, margin: '16px 0', overflowY: 'auto', border: '1px solid #d1d5db' }, children: Array.from({ length: 100 }, (_, index) => `line ${String(index + 1)}`).join('\n') }), _jsx("p", { style: { margin: 0 }, children: "The page ends here." })] }));
@@ -0,0 +1,27 @@
1
+ import type { StoryObj } from '@storybook/react-vite';
2
+ declare const meta: {
3
+ title: string;
4
+ component: ({ sections }: import("./TallPage.js").ITallPageProps) => import("react/jsx-runtime").JSX.Element;
5
+ parameters: {
6
+ layout: string;
7
+ };
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+ /**
12
+ * `fullPage`: qlip grows the viewport (and, under Vitest browser mode, the
13
+ * browser window behind it) until the column no longer scrolls, so the
14
+ * capture is the whole column at 1:1 — the e2e test reads the PNG's height.
15
+ * The manifest keeps the pinned viewport as the baseline key.
16
+ */
17
+ export declare const ScrollsAnInnerBox: Story;
18
+ /** The same page without `fullPage`: one screen, the rest scrolled away. */
19
+ export declare const OneScreen: Story;
20
+ /**
21
+ * `fullPage` on an ordinary page whose only scroller is a fixed-height box:
22
+ * more viewport never reveals it, so qlip gives the growth back and the
23
+ * capture stays one screen — not a screen of page over a tall blank strip.
24
+ * The e2e test reads the PNG's height.
25
+ */
26
+ export declare const FixedHeightBox: Story;
27
+ //# sourceMappingURL=TallPage.stories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TallPage.stories.d.ts","sourceRoot":"","sources":["../../src/stories/TallPage.stories.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAK5D,QAAA,MAAM,IAAI;;;;;;CAMuB,CAAC;AAElC,eAAe,IAAI,CAAC;AACpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AAgBnC;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAK/B,CAAC;AAEF,4EAA4E;AAC5E,eAAO,MAAM,SAAS,EAAE,KAKvB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,cAAc,EAAE,KAU5B,CAAC"}
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { expect, within } from 'storybook/test';
3
+ import { FixedBoxPage, TallPage } from './TallPage.js';
4
+ const meta = {
5
+ title: 'Example/TallPage',
6
+ component: TallPage,
7
+ parameters: {
8
+ layout: 'fullscreen',
9
+ },
10
+ };
11
+ export default meta;
12
+ const PHONE = { width: 390, height: 844 };
13
+ /** The precondition that makes the two captures below differ: at the pinned
14
+ * viewport the column really scrolls, and the document does not. */
15
+ const expectsAnInnerScroller = async ({ canvasElement }) => {
16
+ const canvas = within(canvasElement);
17
+ await expect(canvas.getByText('Section 1')).toBeInTheDocument();
18
+ const column = canvasElement.querySelector('[data-testid="tall-page-scroll"]');
19
+ if (!column)
20
+ throw new Error('column not rendered');
21
+ await expect(column.scrollHeight).toBeGreaterThan(column.clientHeight);
22
+ await expect(document.documentElement.scrollHeight).toBe(window.innerHeight);
23
+ };
24
+ /**
25
+ * `fullPage`: qlip grows the viewport (and, under Vitest browser mode, the
26
+ * browser window behind it) until the column no longer scrolls, so the
27
+ * capture is the whole column at 1:1 — the e2e test reads the PNG's height.
28
+ * The manifest keeps the pinned viewport as the baseline key.
29
+ */
30
+ export const ScrollsAnInnerBox = {
31
+ parameters: {
32
+ qlip: { viewport: PHONE, fullPage: true },
33
+ },
34
+ play: expectsAnInnerScroller,
35
+ };
36
+ /** The same page without `fullPage`: one screen, the rest scrolled away. */
37
+ export const OneScreen = {
38
+ parameters: {
39
+ qlip: { viewport: PHONE },
40
+ },
41
+ play: expectsAnInnerScroller,
42
+ };
43
+ /**
44
+ * `fullPage` on an ordinary page whose only scroller is a fixed-height box:
45
+ * more viewport never reveals it, so qlip gives the growth back and the
46
+ * capture stays one screen — not a screen of page over a tall blank strip.
47
+ * The e2e test reads the PNG's height.
48
+ */
49
+ export const FixedHeightBox = {
50
+ render: () => _jsx(FixedBoxPage, {}),
51
+ parameters: {
52
+ qlip: { viewport: PHONE, fullPage: true },
53
+ },
54
+ play: async ({ canvasElement }) => {
55
+ const box = canvasElement.querySelector('[data-testid="fixed-box"]');
56
+ if (!box)
57
+ throw new Error('box not rendered');
58
+ await expect(box.scrollHeight).toBeGreaterThan(box.clientHeight);
59
+ },
60
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test-runner/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAaH,OAAO,KAAK,EACV,kBAAkB,EAGlB,iBAAiB,EACjB,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAGL,YAAY,EAIb,MAAM,qBAAqB,CAAC;AAS7B;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACnE,UAAU,CAAC,IAAI,EAAE;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;KACvB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACtE;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,CAChB,IAAI,EAAE,mBAAmB,EACzB,OAAO,EAAE,gBAAgB,KACtB,OAAO,CAAC;QAAE,UAAU,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,cAAc,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;CAC1D;AA6HD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,GACtB,MAAM,mBAAmB,EACzB,SAAS,gBAAgB,EACzB,UAAS,mBAAwB,KAChC,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAelC,CAAC;AAuHF,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test-runner/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAkBH,OAAO,KAAK,EACV,kBAAkB,EAGlB,iBAAiB,EACjB,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAGL,YAAY,EAIb,MAAM,qBAAqB,CAAC;AAS7B;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACnE,UAAU,CAAC,IAAI,EAAE;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;KACvB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACtE;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,CAChB,IAAI,EAAE,mBAAmB,EACzB,OAAO,EAAE,gBAAgB,KACtB,OAAO,CAAC;QAAE,UAAU,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,cAAc,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;CAC1D;AA+HD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,GACtB,MAAM,mBAAmB,EACzB,SAAS,gBAAgB,EACzB,UAAS,mBAAwB,KAChC,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAelC,CAAC;AA0JF,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -44,6 +44,7 @@
44
44
  import { promises as fs } from 'node:fs';
45
45
  import path from 'node:path';
46
46
  import { resolveQlipOptions } from '../config/parameters.js';
47
+ import { fullPageCapWarning, growViewportToContent, measureContentHeight, } from '../runtime/fullPage.js';
47
48
  import { DEFAULT_OUTPUT_DIR, DEFAULT_VIEWPORT, MANIFEST_FRAGMENT_DIR, TOOL_NAME, buildAutoScreenshotPath, generateBuildId, } from '../fs/output.js';
48
49
  import { writeEntryFragment, getOrInitStore, nextStepName, peekStore, pushEntry, } from './manifest-store.js';
49
50
  import { applyAnimationControlInPage, applyIgnoreMasksInPage, removeIgnoreMasksInPage, waitForDomIdleInPage, } from './prep-scripts.js';
@@ -64,6 +65,8 @@ const buildResolvedDefaults = (override) => {
64
65
  pauseAnimationsAtEnd: override?.pauseAnimationsAtEnd ?? false,
65
66
  disableBackdropFilter: override?.disableBackdropFilter ?? true,
66
67
  captureOnError: override?.captureOnError ?? false,
68
+ fullPage: override?.fullPage ?? false,
69
+ fullPageMaxHeight: override?.fullPageMaxHeight ?? 10000,
67
70
  waitForIdleMs: override?.waitForIdleMs ?? 300,
68
71
  maxWaitForIdleMs: override?.maxWaitForIdleMs ?? 2000,
69
72
  ignoreElements: override?.ignoreElements ?? [],
@@ -223,6 +226,7 @@ const qlipCaptureInner = async (page, context, options) => {
223
226
  let status = 'captured';
224
227
  let error = null;
225
228
  let maskIds = [];
229
+ let grownTo = null;
226
230
  try {
227
231
  await page.setViewportSize({
228
232
  width: resolved.viewport.width,
@@ -236,6 +240,27 @@ const qlipCaptureInner = async (page, context, options) => {
236
240
  idleMs: resolved.waitForIdleMs,
237
241
  maxWaitMs: resolved.maxWaitForIdleMs,
238
242
  });
243
+ if (resolved.fullPage) {
244
+ // Here the viewport IS the browser window, so growing it is one call;
245
+ // the measure runs inside the page (see ../runtime/fullPage.ts).
246
+ const growth = await growViewportToContent({
247
+ viewport: resolved.viewport,
248
+ maxHeight: resolved.fullPageMaxHeight,
249
+ setViewport: (width, height) => page.setViewportSize({ width, height }),
250
+ measure: (first) => page.evaluate(measureContentHeight, { reset: first }),
251
+ settle: () => page.evaluate(waitForDomIdleInPage, {
252
+ idleMs: resolved.waitForIdleMs,
253
+ maxWaitMs: resolved.maxWaitForIdleMs,
254
+ }),
255
+ });
256
+ if (growth.height !== resolved.viewport.height) {
257
+ grownTo = growth.height;
258
+ }
259
+ const warning = fullPageCapWarning(context.id, growth, resolved.fullPageMaxHeight);
260
+ if (warning) {
261
+ console.warn(warning);
262
+ }
263
+ }
239
264
  if (resolved.ignoreElements.length > 0) {
240
265
  maskIds = await page.evaluate(applyIgnoreMasksInPage, {
241
266
  selectors: resolved.ignoreElements,
@@ -260,6 +285,19 @@ const qlipCaptureInner = async (page, context, options) => {
260
285
  /* page may have navigated away; masks die with it */
261
286
  }
262
287
  }
288
+ if (grownTo !== null) {
289
+ // The pinned viewport is the baseline key; whatever the test-runner
290
+ // does with the page next should see the story's own size again.
291
+ try {
292
+ await page.setViewportSize({
293
+ width: resolved.viewport.width,
294
+ height: resolved.viewport.height,
295
+ });
296
+ }
297
+ catch {
298
+ /* the next capture sets its own viewport anyway */
299
+ }
300
+ }
263
301
  }
264
302
  const entry = buildEntry({
265
303
  kind: 'auto',
package/dist/types.d.ts CHANGED
@@ -4,6 +4,13 @@ export interface QlipViewport {
4
4
  }
5
5
  export interface QlipCaptureOptions {
6
6
  skip?: boolean;
7
+ /**
8
+ * The capture's viewport, and the baseline key on the server. Under
9
+ * Vitest browser mode a viewport the browser window cannot hold grows the
10
+ * window for the capture (and puts it back), because Vitest otherwise
11
+ * scales the test iframe down to fit and the capture comes back smaller
12
+ * than the viewport it names.
13
+ */
7
14
  viewport?: QlipViewport;
8
15
  disableAnimations?: boolean;
9
16
  pauseAnimationsAtEnd?: boolean;
@@ -18,6 +25,38 @@ export interface QlipCaptureOptions {
18
25
  * @default true
19
26
  */
20
27
  disableBackdropFilter?: boolean;
28
+ /**
29
+ * Grow the viewport to the page's full content height for the capture,
30
+ * then put it back. A story that pins a phone viewport is otherwise
31
+ * photographed one screen tall with the rest of the page scrolled away —
32
+ * and a Playwright "full page" shot would not help, because an app shell
33
+ * usually scrolls an inner box, not the document. The height is measured
34
+ * from the document AND every scrolling element (an `overflow-y: auto |
35
+ * scroll` box whose `scrollHeight` exceeds its `clientHeight`), and
36
+ * re-measured after each growth until nothing scrolls. A box that keeps
37
+ * its height when the viewport grows (a fixed-height code block) stays
38
+ * scrolled and gives its growth back, and content that grows with the
39
+ * viewport is not chased. Capped at `fullPageMaxHeight`. Under Vitest
40
+ * browser mode the browser window (the Playwright context viewport) grows
41
+ * with the test iframe,
42
+ * or the capture would be scaled down to fit the window; both are put
43
+ * back afterwards. The manifest still reports the pinned `viewport` —
44
+ * that is the baseline key; the picture is simply taller. A capture whose
45
+ * content height changed shows on the server as a dimensions change
46
+ * rather than a pixel diff.
47
+ *
48
+ * @default false
49
+ */
50
+ fullPage?: boolean;
51
+ /**
52
+ * The tallest a `fullPage` capture may grow to, in CSS pixels. Chromium
53
+ * refuses viewports past 16384; the default leaves room for very long
54
+ * pages without inviting a runaway layout. A page taller than this is
55
+ * captured at the cap, with a `[qlip] fullPage:` warning naming the story.
56
+ *
57
+ * @default 10000
58
+ */
59
+ fullPageMaxHeight?: number;
21
60
  waitForIdleMs?: number;
22
61
  maxWaitForIdleMs?: number;
23
62
  ignoreElements?: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -14,6 +14,10 @@ const resolveQlipOptions = ({ defaults, story, override, }) => {
14
14
  disableBackdropFilter: override?.disableBackdropFilter ??
15
15
  story?.disableBackdropFilter ??
16
16
  defaults.disableBackdropFilter,
17
+ fullPage: override?.fullPage ?? story?.fullPage ?? defaults.fullPage,
18
+ fullPageMaxHeight: override?.fullPageMaxHeight ??
19
+ story?.fullPageMaxHeight ??
20
+ defaults.fullPageMaxHeight,
17
21
  waitForIdleMs: override?.waitForIdleMs ??
18
22
  story?.waitForIdleMs ??
19
23
  defaults.waitForIdleMs,
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ /**
3
+ * A `fullPage` capture grows the viewport until nothing on the page scrolls,
4
+ * photographs it, and puts the viewport back. A story that pins a phone
5
+ * viewport is otherwise one screen tall, and a browser "full page" screenshot
6
+ * would not help: an app shell pins the document to the viewport and scrolls
7
+ * an inner box, so the document is never taller than one screen.
8
+ *
9
+ * Under Vitest browser mode two viewports are involved. `page.viewport()`
10
+ * sizes the test iframe; the real browser window (the Playwright context
11
+ * viewport) stays as configured, and Vitest scales the iframe down to fit it.
12
+ * A 3,600px-tall iframe in a 1,440px window is photographed at 40% — the
13
+ * whole page, unreadable — and so is a plain 844px-tall phone story in a
14
+ * 720px window, at 85%. `createCaptureViewport` therefore grows the window
15
+ * to hold whatever viewport a capture asks for, pinned or grown, and puts
16
+ * both back afterwards, the window first, so the iframe lands at scale 1.
17
+ *
18
+ * This module has no imports: the runtime, the test-runner and the unit
19
+ * project (which has no DOM and feeds it fakes) all share it.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.createCaptureViewport = exports.fullPageCapWarning = exports.growViewportToContent = exports.measureContentHeight = void 0;
23
+ /**
24
+ * How tall the page needs the viewport to be for nothing on it to scroll:
25
+ * the document, and every box that scrolls its own content. A scrolling box
26
+ * asks for the viewport plus whatever it hides — a box that fills the
27
+ * viewport grows with it one for one, whatever sits above or below it — and
28
+ * a box that already fits asks for the viewport minus its spare room.
29
+ * Answers 0 when nothing scrolls.
30
+ *
31
+ * A box only counts while growing the viewport can still reveal it, which
32
+ * only a re-measure after a growth can tell, so the rounds of one capture
33
+ * share a memory (`reset` starts a new one):
34
+ * - A box that kept its height when the viewport grew has a height of its
35
+ * own (a 200px code block, a capped list). More viewport never reveals its
36
+ * content, so it stops counting, and growth it asked for is given back.
37
+ * - A box whose content grew as much as the box did is sized to it (a tile
38
+ * that follows its column). Chasing it only stretches the tile, so it keeps
39
+ * asking for what it asked before it scaled. The document gets the same
40
+ * test (a `vh` hero).
41
+ * - A box whose hidden content shrank was revealed and keeps counting, even
42
+ * when a growth reveals less than it added.
43
+ *
44
+ * The document's own `scrollHeight` is the viewport again once the viewport
45
+ * outgrows it, so its tallest height while it still scrolled is remembered.
46
+ *
47
+ * Closure-free, so the test-runner can hand it to Playwright's
48
+ * `page.evaluate()`, which re-evaluates the function's source inside the
49
+ * page; there it reads the real `document` and `window`. `env` exists for
50
+ * the unit test, which has neither.
51
+ */
52
+ const measureContentHeight = (options) => {
53
+ const doc = options?.env?.doc ?? document;
54
+ const win = options?.env?.win ?? window;
55
+ const holder = win;
56
+ if (options?.reset || !holder.__qlipFullPage) {
57
+ holder.__qlipFullPage = {
58
+ viewport: 0,
59
+ docHeight: 0,
60
+ docOverflow: 0,
61
+ docScales: false,
62
+ boxes: new WeakMap(),
63
+ fixed: new WeakSet(),
64
+ scales: new WeakSet(),
65
+ };
66
+ }
67
+ const memory = holder.__qlipFullPage;
68
+ const viewport = win.innerHeight;
69
+ const grew = memory.viewport > 0 && viewport > memory.viewport + 1;
70
+ const docScroll = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
71
+ const docOverflow = Math.max(0, docScroll - viewport);
72
+ if (grew && memory.docOverflow > 0 && docOverflow >= memory.docOverflow - 1) {
73
+ memory.docScales = true;
74
+ }
75
+ memory.docOverflow = docOverflow;
76
+ if (docOverflow > 1 && !memory.docScales) {
77
+ memory.docHeight = Math.max(memory.docHeight, docScroll);
78
+ }
79
+ let height = memory.docHeight;
80
+ for (const el of Array.from(doc.querySelectorAll('*'))) {
81
+ const seen = memory.boxes.get(el);
82
+ if (!seen) {
83
+ if (el.scrollHeight <= el.clientHeight + 1) {
84
+ continue;
85
+ }
86
+ const overflowY = win.getComputedStyle(el).overflowY;
87
+ if (overflowY !== 'auto' && overflowY !== 'scroll') {
88
+ continue;
89
+ }
90
+ }
91
+ const overflow = Math.max(0, el.scrollHeight - el.clientHeight);
92
+ if (seen && grew) {
93
+ if (el.clientHeight <= seen.clientHeight + 1) {
94
+ memory.fixed.add(el);
95
+ }
96
+ else if (seen.overflow > 0 && overflow >= seen.overflow - 1) {
97
+ memory.scales.add(el);
98
+ }
99
+ }
100
+ if (memory.fixed.has(el)) {
101
+ continue;
102
+ }
103
+ // A box that fits reports its own height as scrollHeight, so its content
104
+ // height is the one it had while it still scrolled.
105
+ const content = overflow > 1 ? Math.max(seen?.content ?? 0, el.scrollHeight) : (seen?.content ?? el.scrollHeight);
106
+ let need;
107
+ if (seen && memory.scales.has(el)) {
108
+ need = seen.need;
109
+ }
110
+ else if (overflow > 1) {
111
+ need = viewport + overflow;
112
+ }
113
+ else {
114
+ need = viewport - Math.max(0, el.clientHeight - content);
115
+ }
116
+ need = Math.ceil(need);
117
+ memory.boxes.set(el, { clientHeight: el.clientHeight, overflow, content, need });
118
+ height = Math.max(height, need);
119
+ }
120
+ memory.viewport = viewport;
121
+ return height;
122
+ };
123
+ exports.measureContentHeight = measureContentHeight;
124
+ /**
125
+ * Grows the viewport until nothing on the page scrolls. Growing the viewport
126
+ * re-lays the page out — a scrolling box that was 700px tall becomes 3,500px
127
+ * and may reveal another box below it that scrolls in turn — so the height is
128
+ * measured again after every growth, until a round asks for nothing more or
129
+ * `maxRounds` is spent. Content that does not grow with the viewport, or
130
+ * grows with it, is `measure`'s call (see `measureContentHeight`); when a
131
+ * round asks for less than the viewport already is, that growth went to such
132
+ * a box, and it is given back.
133
+ *
134
+ * `measure` is told when it is the capture's first round, so it can start a
135
+ * new memory.
136
+ */
137
+ const growViewportToContent = async ({ viewport, maxHeight, setViewport, measure, settle, maxRounds = 4, }) => {
138
+ let height = viewport.height;
139
+ let wanted = 0;
140
+ for (let round = 0; round < maxRounds; round += 1) {
141
+ wanted = Math.ceil(await measure(round === 0));
142
+ const asked = Math.min(Math.max(wanted, viewport.height), maxHeight);
143
+ if (asked === height) {
144
+ break;
145
+ }
146
+ const givingBack = asked < height;
147
+ height = asked;
148
+ await setViewport(viewport.width, height);
149
+ await settle();
150
+ if (givingBack) {
151
+ // Every box still counted asked for no more than this, so it fits.
152
+ break;
153
+ }
154
+ }
155
+ return { height, wanted };
156
+ };
157
+ exports.growViewportToContent = growViewportToContent;
158
+ /**
159
+ * The warning for a `fullPage` capture the height cap cut short, or `null`:
160
+ * a page taller than `fullPageMaxHeight` is captured at the cap, and a cut-off
161
+ * page must not pass for the whole one.
162
+ */
163
+ const fullPageCapWarning = (storyId, growth, maxHeight) => growth.wanted > maxHeight
164
+ ? `[qlip] fullPage: ${storyId} is ${String(growth.wanted)}px tall but was captured at ` +
165
+ `the ${String(maxHeight)}px cap; raise fullPageMaxHeight (Chromium allows up to 16384) ` +
166
+ 'to capture all of it.'
167
+ : null;
168
+ exports.fullPageCapWarning = fullPageCapWarning;
169
+ /**
170
+ * The two viewports a capture sizes, and how each is put back. `set` grows
171
+ * the window to hold the iframe whenever the iframe would not fit (never
172
+ * shrinking it below what it was), then sizes the iframe; the window is
173
+ * asked for its size once. `restore` puts the window back first, so the
174
+ * iframe's pinned size is computed against the original window again, then
175
+ * the iframe — and touches neither when neither moved.
176
+ */
177
+ const createCaptureViewport = ({ pinned, iframe, window: windowViewport, }) => {
178
+ // undefined: not asked yet; null: asked, and there is no window to grow
179
+ let base;
180
+ let windowAt = null;
181
+ let iframeAt = null;
182
+ const same = (a, b) => a.width === b.width && a.height === b.height;
183
+ const growWindow = async (width, height) => {
184
+ if (!windowViewport) {
185
+ return;
186
+ }
187
+ if (base === undefined) {
188
+ base = await windowViewport();
189
+ }
190
+ if (!base) {
191
+ return;
192
+ }
193
+ const target = {
194
+ width: Math.max(base.width, width),
195
+ height: Math.max(base.height, height),
196
+ };
197
+ if (same(target, windowAt ?? base)) {
198
+ return;
199
+ }
200
+ await windowViewport(target);
201
+ windowAt = target;
202
+ };
203
+ return {
204
+ set: async (width, height) => {
205
+ await growWindow(width, height);
206
+ await iframe(width, height);
207
+ iframeAt = { width, height };
208
+ },
209
+ restore: async () => {
210
+ if (base && windowAt && windowViewport) {
211
+ await windowViewport(base);
212
+ windowAt = null;
213
+ }
214
+ if (iframeAt && !same(iframeAt, pinned)) {
215
+ await iframe(pinned.width, pinned.height);
216
+ iframeAt = pinned;
217
+ }
218
+ },
219
+ };
220
+ };
221
+ exports.createCaptureViewport = createCaptureViewport;
@@ -50,6 +50,7 @@ exports.nextStepName = exports.qlipCapture = void 0;
50
50
  const node_fs_1 = require("node:fs");
51
51
  const node_path_1 = __importDefault(require("node:path"));
52
52
  const parameters_js_1 = require("../config/parameters.js");
53
+ const fullPage_js_1 = require("../runtime/fullPage.js");
53
54
  const output_js_1 = require("../fs/output.js");
54
55
  const manifest_store_js_1 = require("./manifest-store.js");
55
56
  Object.defineProperty(exports, "nextStepName", { enumerable: true, get: function () { return manifest_store_js_1.nextStepName; } });
@@ -71,6 +72,8 @@ const buildResolvedDefaults = (override) => {
71
72
  pauseAnimationsAtEnd: override?.pauseAnimationsAtEnd ?? false,
72
73
  disableBackdropFilter: override?.disableBackdropFilter ?? true,
73
74
  captureOnError: override?.captureOnError ?? false,
75
+ fullPage: override?.fullPage ?? false,
76
+ fullPageMaxHeight: override?.fullPageMaxHeight ?? 10000,
74
77
  waitForIdleMs: override?.waitForIdleMs ?? 300,
75
78
  maxWaitForIdleMs: override?.maxWaitForIdleMs ?? 2000,
76
79
  ignoreElements: override?.ignoreElements ?? [],
@@ -231,6 +234,7 @@ const qlipCaptureInner = async (page, context, options) => {
231
234
  let status = 'captured';
232
235
  let error = null;
233
236
  let maskIds = [];
237
+ let grownTo = null;
234
238
  try {
235
239
  await page.setViewportSize({
236
240
  width: resolved.viewport.width,
@@ -244,6 +248,27 @@ const qlipCaptureInner = async (page, context, options) => {
244
248
  idleMs: resolved.waitForIdleMs,
245
249
  maxWaitMs: resolved.maxWaitForIdleMs,
246
250
  });
251
+ if (resolved.fullPage) {
252
+ // Here the viewport IS the browser window, so growing it is one call;
253
+ // the measure runs inside the page (see ../runtime/fullPage.ts).
254
+ const growth = await (0, fullPage_js_1.growViewportToContent)({
255
+ viewport: resolved.viewport,
256
+ maxHeight: resolved.fullPageMaxHeight,
257
+ setViewport: (width, height) => page.setViewportSize({ width, height }),
258
+ measure: (first) => page.evaluate(fullPage_js_1.measureContentHeight, { reset: first }),
259
+ settle: () => page.evaluate(prep_scripts_js_1.waitForDomIdleInPage, {
260
+ idleMs: resolved.waitForIdleMs,
261
+ maxWaitMs: resolved.maxWaitForIdleMs,
262
+ }),
263
+ });
264
+ if (growth.height !== resolved.viewport.height) {
265
+ grownTo = growth.height;
266
+ }
267
+ const warning = (0, fullPage_js_1.fullPageCapWarning)(context.id, growth, resolved.fullPageMaxHeight);
268
+ if (warning) {
269
+ console.warn(warning);
270
+ }
271
+ }
247
272
  if (resolved.ignoreElements.length > 0) {
248
273
  maskIds = await page.evaluate(prep_scripts_js_1.applyIgnoreMasksInPage, {
249
274
  selectors: resolved.ignoreElements,
@@ -268,6 +293,19 @@ const qlipCaptureInner = async (page, context, options) => {
268
293
  /* page may have navigated away; masks die with it */
269
294
  }
270
295
  }
296
+ if (grownTo !== null) {
297
+ // The pinned viewport is the baseline key; whatever the test-runner
298
+ // does with the page next should see the story's own size again.
299
+ try {
300
+ await page.setViewportSize({
301
+ width: resolved.viewport.width,
302
+ height: resolved.viewport.height,
303
+ });
304
+ }
305
+ catch {
306
+ /* the next capture sets its own viewport anyway */
307
+ }
308
+ }
271
309
  }
272
310
  const entry = buildEntry({
273
311
  kind: 'auto',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/qlip",
3
- "version": "0.1.3-pr.29.g497a7aa",
3
+ "version": "0.1.3-pr.30.gc7b05dc",
4
4
  "description": "Storybook screenshot capture for the Vitest addon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",