@qoretechnologies/qlip 0.1.3-beta.20260908110821 → 0.1.3-beta.20260929120101

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
@@ -194,8 +194,11 @@ export const LoggedIn = {
194
194
  qlip: {
195
195
  skip: false,
196
196
  viewport: { width: 1280, height: 720 },
197
+ fullSizeCaptures: false,
197
198
  disableAnimations: true,
198
199
  pauseAnimationsAtEnd: false,
200
+ fullPage: false,
201
+ fullPageMaxHeight: 10000,
199
202
  captureOnError: false,
200
203
  waitForIdleMs: 300,
201
204
  maxWaitForIdleMs: 2000,
@@ -217,8 +220,11 @@ Options precedence:
217
220
  qlipVitestPlugin({
218
221
  outputDir: './qlip/screenshots',
219
222
  viewport: { width: 1280, height: 720 },
223
+ fullSizeCaptures: false,
220
224
  disableAnimations: false,
221
225
  pauseAnimationsAtEnd: false,
226
+ fullPage: false,
227
+ fullPageMaxHeight: 10000,
222
228
  captureOnError: false,
223
229
  waitForIdleMs: 300,
224
230
  maxWaitForIdleMs: 2000,
@@ -227,6 +233,12 @@ qlipVitestPlugin({
227
233
  });
228
234
  ```
229
235
 
236
+ `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 a `fullPage` capture also grows the window — the Playwright context viewport — through a browser command the plugin registers (`qlipBrowserViewport`), and restores it first; the capture is 1:1, not a shrunk picture of a tall page. `fullSizeCaptures` does the same for every capture (below). 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.
237
+
238
+ `fullSizeCaptures` captures every story at the full size of its `viewport`. Under Vitest browser mode the browser window stays at the Playwright context viewport — 1280×720 headless, unless you configure it — whatever the story's `viewport` says, and Vitest scales the test iframe down to fit the window. The layout is the story's, but the pixels are shrunk: a 1920×1080 story comes back 1280×720, a 390×844 phone story 333×720, with its text and any fine difference shrunk with it. With `fullSizeCaptures: true` the window grows to hold the viewport for each capture and is put back afterwards, so the PNG is exactly the viewport. The runner path always captures at full size, and `fullPage` always grows the window.
239
+
240
+ It is off by default, so upgrading qlip changes no baselines, and it will become the default in a future minor release. Turning it on changes the size of every capture whose viewport is larger than the window (stories that fit it are unaffected), and the server reports each of those as changed against its baseline, a dimensions change. Turn it on in one change on your default branch and accept those captures once.
241
+
230
242
  `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
243
 
232
244
  `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.
@@ -5,9 +5,12 @@ export interface QlipResolvedOptions {
5
5
  width: number;
6
6
  height: number;
7
7
  };
8
+ fullSizeCaptures: boolean;
8
9
  disableAnimations: boolean;
9
10
  pauseAnimationsAtEnd: boolean;
10
11
  disableBackdropFilter: boolean;
12
+ fullPage: boolean;
13
+ fullPageMaxHeight: number;
11
14
  waitForIdleMs: number;
12
15
  maxWaitForIdleMs: number;
13
16
  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,gBAAgB,EAAE,OAAO,CAAC;IAC1B,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,mBAyDH,CAAC"}
@@ -2,6 +2,9 @@ export const resolveQlipOptions = ({ defaults, story, override, }) => {
2
2
  return {
3
3
  skip: override?.skip ?? story?.skip ?? defaults.skip,
4
4
  viewport: override?.viewport ?? story?.viewport ?? defaults.viewport,
5
+ fullSizeCaptures: override?.fullSizeCaptures ??
6
+ story?.fullSizeCaptures ??
7
+ defaults.fullSizeCaptures,
5
8
  disableAnimations: override?.disableAnimations ??
6
9
  story?.disableAnimations ??
7
10
  defaults.disableAnimations,
@@ -11,6 +14,10 @@ export const resolveQlipOptions = ({ defaults, story, override, }) => {
11
14
  disableBackdropFilter: override?.disableBackdropFilter ??
12
15
  story?.disableBackdropFilter ??
13
16
  defaults.disableBackdropFilter,
17
+ fullPage: override?.fullPage ?? story?.fullPage ?? defaults.fullPage,
18
+ fullPageMaxHeight: override?.fullPageMaxHeight ??
19
+ story?.fullPageMaxHeight ??
20
+ defaults.fullPageMaxHeight,
14
21
  waitForIdleMs: override?.waitForIdleMs ??
15
22
  story?.waitForIdleMs ??
16
23
  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,MAkRlE,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();
@@ -30,6 +33,9 @@ export const qlipVitestPlugin = (options = {}) => {
30
33
  defaults: {
31
34
  outputDir,
32
35
  viewport: options.viewport ?? DEFAULT_VIEWPORT,
36
+ // Off by default — turning it on resizes every capture that was
37
+ // being scaled to fit the window. See QlipCaptureOptions.
38
+ fullSizeCaptures: options.fullSizeCaptures ?? false,
33
39
  skip: false,
34
40
  disableAnimations: options.disableAnimations ?? false,
35
41
  pauseAnimationsAtEnd: options.pauseAnimationsAtEnd ?? false,
@@ -37,6 +43,8 @@ export const qlipVitestPlugin = (options = {}) => {
37
43
  // never a wanted baseline. See QlipCaptureOptions.
38
44
  disableBackdropFilter: options.disableBackdropFilter ?? true,
39
45
  captureOnError: options.captureOnError ?? false,
46
+ fullPage: options.fullPage ?? false,
47
+ fullPageMaxHeight: options.fullPageMaxHeight ?? DEFAULT_FULL_PAGE_MAX_HEIGHT,
40
48
  waitForIdleMs: options.waitForIdleMs ?? 300,
41
49
  maxWaitForIdleMs: options.maxWaitForIdleMs ?? 2000,
42
50
  ignoreElements: options.ignoreElements ?? [],
@@ -185,6 +193,16 @@ export const qlipVitestPlugin = (options = {}) => {
185
193
  setupFiles: [setupFile],
186
194
  globalSetup: [globalSetupFile],
187
195
  reporters: reportersList,
196
+ // The runtime grows the browser window for a `fullPage` capture,
197
+ // and for every capture with `fullSizeCaptures`, through this
198
+ // command; it runs here, where the Playwright page is.
199
+ // `mergeConfig` merges it beside any commands the consumer
200
+ // declares.
201
+ browser: {
202
+ commands: {
203
+ [QLIP_BROWSER_VIEWPORT_COMMAND]: qlipBrowserViewport,
204
+ },
205
+ },
188
206
  },
189
207
  };
190
208
  },
@@ -0,0 +1,120 @@
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
+ * when it is given one, to hold whatever viewport a capture asks for, pinned
15
+ * or grown, and puts both back afterwards, the window first, so the iframe
16
+ * lands at scale 1. The runtime gives it the window for a `fullPage` capture
17
+ * and, with `fullSizeCaptures` on, for every capture.
18
+ *
19
+ * This module has no imports: the runtime, the test-runner and the unit
20
+ * project (which has no DOM and feeds it fakes) all share it.
21
+ */
22
+ export interface FullPageSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ /**
27
+ * How tall the page needs the viewport to be for nothing on it to scroll:
28
+ * the document, and every box that scrolls its own content. A scrolling box
29
+ * asks for the viewport plus whatever it hides — a box that fills the
30
+ * viewport grows with it one for one, whatever sits above or below it — and
31
+ * a box that already fits asks for the viewport minus its spare room.
32
+ * Answers 0 when nothing scrolls.
33
+ *
34
+ * A box only counts while growing the viewport can still reveal it, which
35
+ * only a re-measure after a growth can tell, so the rounds of one capture
36
+ * share a memory (`reset` starts a new one):
37
+ * - A box that kept its height when the viewport grew has a height of its
38
+ * own (a 200px code block, a capped list). More viewport never reveals its
39
+ * content, so it stops counting, and growth it asked for is given back.
40
+ * - A box whose content grew as much as the box did is sized to it (a tile
41
+ * that follows its column). Chasing it only stretches the tile, so it keeps
42
+ * asking for what it asked before it scaled. The document gets the same
43
+ * test (a `vh` hero).
44
+ * - A box whose hidden content shrank was revealed and keeps counting, even
45
+ * when a growth reveals less than it added.
46
+ *
47
+ * The document's own `scrollHeight` is the viewport again once the viewport
48
+ * outgrows it, so its tallest height while it still scrolled is remembered.
49
+ *
50
+ * Closure-free, so the test-runner can hand it to Playwright's
51
+ * `page.evaluate()`, which re-evaluates the function's source inside the
52
+ * page; there it reads the real `document` and `window`. `env` exists for
53
+ * the unit test, which has neither.
54
+ */
55
+ export declare const measureContentHeight: (options?: {
56
+ reset?: boolean;
57
+ env?: {
58
+ doc: Pick<Document, "documentElement" | "body" | "querySelectorAll">;
59
+ win: Pick<Window, "getComputedStyle" | "innerHeight">;
60
+ };
61
+ }) => number;
62
+ /** Where `growViewportToContent` left the viewport, and why. */
63
+ export interface FullPageGrowth {
64
+ /** The height the viewport ended at: the pinned height when nothing grew. */
65
+ height: number;
66
+ /** What the content last asked for, before the `maxHeight` cap. */
67
+ wanted: number;
68
+ }
69
+ /**
70
+ * Grows the viewport until nothing on the page scrolls. Growing the viewport
71
+ * re-lays the page out — a scrolling box that was 700px tall becomes 3,500px
72
+ * and may reveal another box below it that scrolls in turn — so the height is
73
+ * measured again after every growth, until a round asks for nothing more or
74
+ * `maxRounds` is spent. Content that does not grow with the viewport, or
75
+ * grows with it, is `measure`'s call (see `measureContentHeight`); when a
76
+ * round asks for less than the viewport already is, that growth went to such
77
+ * a box, and it is given back.
78
+ *
79
+ * `measure` is told when it is the capture's first round, so it can start a
80
+ * new memory.
81
+ */
82
+ export declare const growViewportToContent: ({ viewport, maxHeight, setViewport, measure, settle, maxRounds, }: {
83
+ viewport: FullPageSize;
84
+ maxHeight: number;
85
+ setViewport: (width: number, height: number) => Promise<void>;
86
+ measure: (first: boolean) => number | Promise<number>;
87
+ settle: () => Promise<void>;
88
+ maxRounds?: number;
89
+ }) => Promise<FullPageGrowth>;
90
+ /**
91
+ * The warning for a `fullPage` capture the height cap cut short, or `null`:
92
+ * a page taller than `fullPageMaxHeight` is captured at the cap, and a cut-off
93
+ * page must not pass for the whole one.
94
+ */
95
+ export declare const fullPageCapWarning: (storyId: string, growth: FullPageGrowth, maxHeight: number) => string | null;
96
+ /**
97
+ * The browser-window half of the viewport, as the qlip plugin's browser
98
+ * command exposes it: called without a size it answers the window's current
99
+ * viewport, with one it resizes the window and answers the previous size.
100
+ * `null` means the provider has no window viewport to grow — webdriverio
101
+ * resizes the real window from `page.viewport()` itself and scales nothing.
102
+ */
103
+ export type BrowserViewportCommand = (size?: FullPageSize | null) => Promise<FullPageSize | null>;
104
+ /**
105
+ * The two viewports a capture sizes, and how each is put back. `set` grows
106
+ * the window to hold the iframe whenever the iframe would not fit (never
107
+ * shrinking it below what it was), then sizes the iframe; the window is
108
+ * asked for its size once. `restore` puts the window back first, so the
109
+ * iframe's pinned size is computed against the original window again, then
110
+ * the iframe — and touches neither when neither moved.
111
+ */
112
+ export declare const createCaptureViewport: ({ pinned, iframe, window: windowViewport, }: {
113
+ pinned: FullPageSize;
114
+ iframe: (width: number, height: number) => Promise<void>;
115
+ window?: BrowserViewportCommand | undefined;
116
+ }) => {
117
+ set: (width: number, height: number) => Promise<void>;
118
+ restore: () => Promise<void>;
119
+ };
120
+ //# sourceMappingURL=fullPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fullPage.d.ts","sourceRoot":"","sources":["../../src/runtime/fullPage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;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,216 @@
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
+ * when it is given one, to hold whatever viewport a capture asks for, pinned
15
+ * or grown, and puts both back afterwards, the window first, so the iframe
16
+ * lands at scale 1. The runtime gives it the window for a `fullPage` capture
17
+ * and, with `fullSizeCaptures` on, for every capture.
18
+ *
19
+ * This module has no imports: the runtime, the test-runner and the unit
20
+ * project (which has no DOM and feeds it fakes) all share it.
21
+ */
22
+ /**
23
+ * How tall the page needs the viewport to be for nothing on it to scroll:
24
+ * the document, and every box that scrolls its own content. A scrolling box
25
+ * asks for the viewport plus whatever it hides — a box that fills the
26
+ * viewport grows with it one for one, whatever sits above or below it — and
27
+ * a box that already fits asks for the viewport minus its spare room.
28
+ * Answers 0 when nothing scrolls.
29
+ *
30
+ * A box only counts while growing the viewport can still reveal it, which
31
+ * only a re-measure after a growth can tell, so the rounds of one capture
32
+ * share a memory (`reset` starts a new one):
33
+ * - A box that kept its height when the viewport grew has a height of its
34
+ * own (a 200px code block, a capped list). More viewport never reveals its
35
+ * content, so it stops counting, and growth it asked for is given back.
36
+ * - A box whose content grew as much as the box did is sized to it (a tile
37
+ * that follows its column). Chasing it only stretches the tile, so it keeps
38
+ * asking for what it asked before it scaled. The document gets the same
39
+ * test (a `vh` hero).
40
+ * - A box whose hidden content shrank was revealed and keeps counting, even
41
+ * when a growth reveals less than it added.
42
+ *
43
+ * The document's own `scrollHeight` is the viewport again once the viewport
44
+ * outgrows it, so its tallest height while it still scrolled is remembered.
45
+ *
46
+ * Closure-free, so the test-runner can hand it to Playwright's
47
+ * `page.evaluate()`, which re-evaluates the function's source inside the
48
+ * page; there it reads the real `document` and `window`. `env` exists for
49
+ * the unit test, which has neither.
50
+ */
51
+ export const measureContentHeight = (options) => {
52
+ const doc = options?.env?.doc ?? document;
53
+ const win = options?.env?.win ?? window;
54
+ const holder = win;
55
+ if (options?.reset || !holder.__qlipFullPage) {
56
+ holder.__qlipFullPage = {
57
+ viewport: 0,
58
+ docHeight: 0,
59
+ docOverflow: 0,
60
+ docScales: false,
61
+ boxes: new WeakMap(),
62
+ fixed: new WeakSet(),
63
+ scales: new WeakSet(),
64
+ };
65
+ }
66
+ const memory = holder.__qlipFullPage;
67
+ const viewport = win.innerHeight;
68
+ const grew = memory.viewport > 0 && viewport > memory.viewport + 1;
69
+ const docScroll = Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0);
70
+ const docOverflow = Math.max(0, docScroll - viewport);
71
+ if (grew && memory.docOverflow > 0 && docOverflow >= memory.docOverflow - 1) {
72
+ memory.docScales = true;
73
+ }
74
+ memory.docOverflow = docOverflow;
75
+ if (docOverflow > 1 && !memory.docScales) {
76
+ memory.docHeight = Math.max(memory.docHeight, docScroll);
77
+ }
78
+ let height = memory.docHeight;
79
+ for (const el of Array.from(doc.querySelectorAll('*'))) {
80
+ const seen = memory.boxes.get(el);
81
+ if (!seen) {
82
+ if (el.scrollHeight <= el.clientHeight + 1) {
83
+ continue;
84
+ }
85
+ const overflowY = win.getComputedStyle(el).overflowY;
86
+ if (overflowY !== 'auto' && overflowY !== 'scroll') {
87
+ continue;
88
+ }
89
+ }
90
+ const overflow = Math.max(0, el.scrollHeight - el.clientHeight);
91
+ if (seen && grew) {
92
+ if (el.clientHeight <= seen.clientHeight + 1) {
93
+ memory.fixed.add(el);
94
+ }
95
+ else if (seen.overflow > 0 && overflow >= seen.overflow - 1) {
96
+ memory.scales.add(el);
97
+ }
98
+ }
99
+ if (memory.fixed.has(el)) {
100
+ continue;
101
+ }
102
+ // A box that fits reports its own height as scrollHeight, so its content
103
+ // height is the one it had while it still scrolled.
104
+ const content = overflow > 1 ? Math.max(seen?.content ?? 0, el.scrollHeight) : (seen?.content ?? el.scrollHeight);
105
+ let need;
106
+ if (seen && memory.scales.has(el)) {
107
+ need = seen.need;
108
+ }
109
+ else if (overflow > 1) {
110
+ need = viewport + overflow;
111
+ }
112
+ else {
113
+ need = viewport - Math.max(0, el.clientHeight - content);
114
+ }
115
+ need = Math.ceil(need);
116
+ memory.boxes.set(el, { clientHeight: el.clientHeight, overflow, content, need });
117
+ height = Math.max(height, need);
118
+ }
119
+ memory.viewport = viewport;
120
+ return height;
121
+ };
122
+ /**
123
+ * Grows the viewport until nothing on the page scrolls. Growing the viewport
124
+ * re-lays the page out — a scrolling box that was 700px tall becomes 3,500px
125
+ * and may reveal another box below it that scrolls in turn — so the height is
126
+ * measured again after every growth, until a round asks for nothing more or
127
+ * `maxRounds` is spent. Content that does not grow with the viewport, or
128
+ * grows with it, is `measure`'s call (see `measureContentHeight`); when a
129
+ * round asks for less than the viewport already is, that growth went to such
130
+ * a box, and it is given back.
131
+ *
132
+ * `measure` is told when it is the capture's first round, so it can start a
133
+ * new memory.
134
+ */
135
+ export const growViewportToContent = async ({ viewport, maxHeight, setViewport, measure, settle, maxRounds = 4, }) => {
136
+ let height = viewport.height;
137
+ let wanted = 0;
138
+ for (let round = 0; round < maxRounds; round += 1) {
139
+ wanted = Math.ceil(await measure(round === 0));
140
+ const asked = Math.min(Math.max(wanted, viewport.height), maxHeight);
141
+ if (asked === height) {
142
+ break;
143
+ }
144
+ const givingBack = asked < height;
145
+ height = asked;
146
+ await setViewport(viewport.width, height);
147
+ await settle();
148
+ if (givingBack) {
149
+ // Every box still counted asked for no more than this, so it fits.
150
+ break;
151
+ }
152
+ }
153
+ return { height, wanted };
154
+ };
155
+ /**
156
+ * The warning for a `fullPage` capture the height cap cut short, or `null`:
157
+ * a page taller than `fullPageMaxHeight` is captured at the cap, and a cut-off
158
+ * page must not pass for the whole one.
159
+ */
160
+ export const fullPageCapWarning = (storyId, growth, maxHeight) => growth.wanted > maxHeight
161
+ ? `[qlip] fullPage: ${storyId} is ${String(growth.wanted)}px tall but was captured at ` +
162
+ `the ${String(maxHeight)}px cap; raise fullPageMaxHeight (Chromium allows up to 16384) ` +
163
+ 'to capture all of it.'
164
+ : null;
165
+ /**
166
+ * The two viewports a capture sizes, and how each is put back. `set` grows
167
+ * the window to hold the iframe whenever the iframe would not fit (never
168
+ * shrinking it below what it was), then sizes the iframe; the window is
169
+ * asked for its size once. `restore` puts the window back first, so the
170
+ * iframe's pinned size is computed against the original window again, then
171
+ * the iframe — and touches neither when neither moved.
172
+ */
173
+ export const createCaptureViewport = ({ pinned, iframe, window: windowViewport, }) => {
174
+ // undefined: not asked yet; null: asked, and there is no window to grow
175
+ let base;
176
+ let windowAt = null;
177
+ let iframeAt = null;
178
+ const same = (a, b) => a.width === b.width && a.height === b.height;
179
+ const growWindow = async (width, height) => {
180
+ if (!windowViewport) {
181
+ return;
182
+ }
183
+ if (base === undefined) {
184
+ base = await windowViewport();
185
+ }
186
+ if (!base) {
187
+ return;
188
+ }
189
+ const target = {
190
+ width: Math.max(base.width, width),
191
+ height: Math.max(base.height, height),
192
+ };
193
+ if (same(target, windowAt ?? base)) {
194
+ return;
195
+ }
196
+ await windowViewport(target);
197
+ windowAt = target;
198
+ };
199
+ return {
200
+ set: async (width, height) => {
201
+ await growWindow(width, height);
202
+ await iframe(width, height);
203
+ iframeAt = { width, height };
204
+ },
205
+ restore: async () => {
206
+ if (base && windowAt && windowViewport) {
207
+ await windowViewport(base);
208
+ windowAt = null;
209
+ }
210
+ if (iframeAt && !same(iframeAt, pinned)) {
211
+ await iframe(pinned.width, pinned.height);
212
+ iframeAt = pinned;
213
+ }
214
+ },
215
+ };
216
+ };
@@ -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;AAicF,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,39 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
490
491
  // throws.
491
492
  let error = testError ?? null;
492
493
  let cleanupMasks = null;
494
+ // Unless the browser window grows with it, a viewport larger than the
495
+ // window is captured scaled down to fit it (see ./fullPage.ts). Growing it
496
+ // resizes captures that already have baselines, so it is opt-in
497
+ // (`fullSizeCaptures`); a `fullPage` capture always grows it, since scaled
498
+ // it is unreadable.
499
+ const captureViewport = createCaptureViewport({
500
+ pinned: resolved.viewport,
501
+ iframe: (width, height) => page.viewport(width, height),
502
+ window: resolved.fullSizeCaptures || resolved.fullPage
503
+ ? commands.qlipBrowserViewport
504
+ : undefined,
505
+ });
493
506
  try {
494
- await page.viewport(resolved.viewport.width, resolved.viewport.height);
507
+ await captureViewport.set(resolved.viewport.width, resolved.viewport.height);
495
508
  applyCaptureStyleOverrides({
496
509
  disableAnimations: resolved.disableAnimations,
497
510
  pauseAnimationsAtEnd: resolved.pauseAnimationsAtEnd,
498
511
  disableBackdropFilter: resolved.disableBackdropFilter,
499
512
  });
500
513
  await waitForDomIdle(resolved.waitForIdleMs, resolved.maxWaitForIdleMs);
514
+ if (resolved.fullPage) {
515
+ const growth = await growViewportToContent({
516
+ viewport: resolved.viewport,
517
+ maxHeight: resolved.fullPageMaxHeight,
518
+ setViewport: captureViewport.set,
519
+ measure: (first) => measureContentHeight({ reset: first }),
520
+ settle: () => waitForDomIdle(resolved.waitForIdleMs, resolved.maxWaitForIdleMs),
521
+ });
522
+ const warning = fullPageCapWarning(story.id, growth, resolved.fullPageMaxHeight);
523
+ if (warning) {
524
+ console.warn(warning);
525
+ }
526
+ }
501
527
  cleanupMasks = applyIgnoreMasks(resolved.ignoreElements);
502
528
  await page.screenshot({ path: absolutePath, save: true });
503
529
  }
@@ -513,6 +539,15 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
513
539
  if (cleanupMasks) {
514
540
  cleanupMasks();
515
541
  }
542
+ // The pinned viewport is the baseline key and what the next capture
543
+ // inherits; a failure to put it back must not hide the capture's own
544
+ // outcome.
545
+ try {
546
+ await captureViewport.restore();
547
+ }
548
+ catch {
549
+ // The suite's own viewport reset runs before the next story.
550
+ }
516
551
  }
517
552
  const flushed = flushConsoleLogs(runtime);
518
553
  let logsPath;
@@ -9,6 +9,13 @@ declare const meta: {
9
9
  export default meta;
10
10
  type Story = StoryObj<typeof meta>;
11
11
  export declare const LoggedOut: Story;
12
+ /**
13
+ * A desktop viewport wider and taller than the 1280×720 headless browser
14
+ * window. `fullSizeCaptures` grows the window to hold it, so the capture is
15
+ * 1920×1080; without it Vitest would scale the story down to 1280×720. The
16
+ * e2e test reads the PNG.
17
+ */
18
+ export declare const DesktopFullSize: Story;
12
19
  export declare const LoggedIn: Story;
13
20
  export declare const ElementWithFullHeight: Story;
14
21
  export declare const WithConsoleError: Story;
@@ -1 +1 @@
1
- {"version":3,"file":"Page.stories.d.ts","sourceRoot":"","sources":["../../src/stories/Page.stories.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAO5D,QAAA,MAAM,IAAI;;;;;;CAOmB,CAAC;AAE9B,eAAe,IAAI,CAAC;AACpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AAGnC,eAAO,MAAM,SAAS,EAAE,KAAU,CAAC;AAGnC,eAAO,MAAM,QAAQ,EAAE,KAatB,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,KAQnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,KAS9B,CAAC"}
1
+ {"version":3,"file":"Page.stories.d.ts","sourceRoot":"","sources":["../../src/stories/Page.stories.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAO5D,QAAA,MAAM,IAAI;;;;;;CAOmB,CAAC;AAE9B,eAAe,IAAI,CAAC;AACpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AAGnC,eAAO,MAAM,SAAS,EAAE,KAAU,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,KAI7B,CAAC;AAGF,eAAO,MAAM,QAAQ,EAAE,KAatB,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,KAQnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,KAS9B,CAAC"}
@@ -12,6 +12,17 @@ const meta = {
12
12
  };
13
13
  export default meta;
14
14
  export const LoggedOut = {};
15
+ /**
16
+ * A desktop viewport wider and taller than the 1280×720 headless browser
17
+ * window. `fullSizeCaptures` grows the window to hold it, so the capture is
18
+ * 1920×1080; without it Vitest would scale the story down to 1280×720. The
19
+ * e2e test reads the PNG.
20
+ */
21
+ export const DesktopFullSize = {
22
+ parameters: {
23
+ qlip: { viewport: { width: 1920, height: 1080 }, fullSizeCaptures: true },
24
+ },
25
+ };
15
26
  // More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing
16
27
  export const LoggedIn = {
17
28
  play: async (ctx) => {