@pygmalionjs/pygmalion 0.2.26 → 0.2.27

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.
@@ -589,6 +589,11 @@ async function installStoryboardPreset(context, page, preset) {
589
589
  // The script runs again after an opaque initial page reaches the app origin.
590
590
  }
591
591
  }, preset);
592
+ await applyStoryboardPresetMedia(page, preset);
593
+ }
594
+
595
+ async function applyStoryboardPresetMedia(page, preset) {
596
+ if (!preset) return;
592
597
  const media = preset.media ?? preset;
593
598
  if (media.colorScheme || media.reducedMotion) {
594
599
  await page.emulateMedia({
@@ -598,6 +603,32 @@ async function installStoryboardPreset(context, page, preset) {
598
603
  }
599
604
  }
600
605
 
606
+ /**
607
+ * Writes the case environment into a page that is already running the app.
608
+ *
609
+ * The cold path passes the environment through the URL so the bootstrap can
610
+ * apply it before the first render. A warm page has already rendered, so the
611
+ * same declaration is written directly and the preset hook applies the state.
612
+ */
613
+ async function declareWarmStoryboardEnvironment(page, environment) {
614
+ if (!environment || typeof environment !== 'object') return;
615
+ await page.evaluate((value) => {
616
+ const overlay = (storage, entries) => {
617
+ if (!storage || !entries || typeof entries !== 'object') return;
618
+ for (const [key, item] of Object.entries(entries)) {
619
+ try {
620
+ if (item == null) storage.removeItem(key);
621
+ else storage.setItem(key, String(item));
622
+ } catch {
623
+ // Private mode or quota — the declaration stays in-memory only.
624
+ }
625
+ }
626
+ };
627
+ overlay(window.localStorage, value.localStorage ?? value.storage?.local);
628
+ overlay(window.sessionStorage, value.sessionStorage ?? value.storage?.session);
629
+ }, environment);
630
+ }
631
+
601
632
  async function executeStoryboardPreset(page, screenCase, route) {
602
633
  if (!screenCase.preset) return;
603
634
  const request = {
@@ -1002,6 +1033,25 @@ function captureResult({
1002
1033
  * Pygmalion owns the deterministic browser lifecycle and QA status model. The
1003
1034
  * host application owns only domain setup through hooks.
1004
1035
  */
1036
+ /**
1037
+ * Reports whether a case can be captured on a page that is already warm.
1038
+ *
1039
+ * Reuse requires a declared state and no interactions. An interaction leaves
1040
+ * the page in a state only that case knows how to undo — an open menu or modal
1041
+ * would leak into the next snapshot — so those cases always get a fresh page.
1042
+ * The host names the reusable group with `session`; Pygmalion never guesses it.
1043
+ */
1044
+ export function storyboardCaptureSessionKey(screenCase) {
1045
+ const session =
1046
+ typeof screenCase?.session === 'string' ? screenCase.session.trim() : '';
1047
+ if (!session) return null;
1048
+ if ((screenCase.interactions ?? []).length > 0) return null;
1049
+ const route = resolveStoryboardCaptureRoute(screenCase);
1050
+ if (route == null || route === '') return null;
1051
+ const viewport = captureViewport(screenCase);
1052
+ return `${session}|${route}|${viewport.width}x${viewport.height}`;
1053
+ }
1054
+
1005
1055
  export async function captureStoryboardCase({
1006
1056
  browser,
1007
1057
  baseUrl,
@@ -1015,6 +1065,7 @@ export async function captureStoryboardCase({
1015
1065
  navigationOptions = {},
1016
1066
  screenshotOptions = {},
1017
1067
  stability = {},
1068
+ session = null,
1018
1069
  } = {}) {
1019
1070
  if (!browser || typeof browser.newContext !== 'function') {
1020
1071
  throw new TypeError(
@@ -1041,52 +1092,76 @@ export async function captureStoryboardCase({
1041
1092
  });
1042
1093
  }
1043
1094
 
1044
- let context = null;
1045
- let page = null;
1095
+ // A warm session keeps the application booted between cases: the state is
1096
+ // declared, so the next case only has to switch it. Boot is the expensive part
1097
+ // (one per screen previously), and it is the part reuse removes.
1098
+ const warm = Boolean(session?.page);
1099
+ let context = session?.context ?? null;
1100
+ let page = session?.page ?? null;
1046
1101
  let primaryError = null;
1047
1102
  let evidence = {};
1048
1103
  const evidenceErrors = [];
1049
1104
  try {
1050
1105
  throwIfAborted(signal);
1051
- context = await atCaptureStage('setup', () =>
1052
- browser.newContext({
1053
- viewport,
1054
- colorScheme: 'light',
1055
- reducedMotion: 'reduce',
1056
- locale: 'en-US',
1057
- timezoneId: 'UTC',
1058
- ...contextOptions,
1059
- viewport,
1060
- }),
1061
- );
1062
- await atCaptureStage('setup', () =>
1063
- context.addInitScript(() => {
1064
- let seed = 0x5f3759df;
1065
- Math.random = () => {
1066
- seed = (seed * 1664525 + 1013904223) >>> 0;
1067
- return seed / 0x100000000;
1068
- };
1069
- }),
1070
- );
1071
- page = await atCaptureStage('setup', () => context.newPage());
1106
+ if (!warm) {
1107
+ context = await atCaptureStage('setup', () =>
1108
+ browser.newContext({
1109
+ viewport,
1110
+ colorScheme: 'light',
1111
+ reducedMotion: 'reduce',
1112
+ locale: 'en-US',
1113
+ timezoneId: 'UTC',
1114
+ ...contextOptions,
1115
+ viewport,
1116
+ }),
1117
+ );
1118
+ await atCaptureStage('setup', () =>
1119
+ context.addInitScript(() => {
1120
+ let seed = 0x5f3759df;
1121
+ Math.random = () => {
1122
+ seed = (seed * 1664525 + 1013904223) >>> 0;
1123
+ return seed / 0x100000000;
1124
+ };
1125
+ }),
1126
+ );
1127
+ page = await atCaptureStage('setup', () => context.newPage());
1128
+ if (session) {
1129
+ session.context = context;
1130
+ session.page = page;
1131
+ }
1132
+ }
1072
1133
 
1073
1134
  await atCaptureStage('preset', async () => {
1074
1135
  throwIfAborted(signal);
1075
1136
  const fixedTime = resolveStoryboardFixedTime(screenCase.preset);
1076
1137
  if (fixedTime) await page.clock.setFixedTime(fixedTime);
1077
- await hooks.beforePreset?.({ context, page, screenCase, route, signal });
1078
- await installStoryboardPreset(context, page, screenCase.preset);
1138
+ await hooks.beforePreset?.({
1139
+ context,
1140
+ page,
1141
+ screenCase,
1142
+ route,
1143
+ signal,
1144
+ warm,
1145
+ });
1146
+ if (warm) {
1147
+ await declareWarmStoryboardEnvironment(page, screenCase.environment);
1148
+ await applyStoryboardPresetMedia(page, screenCase.preset);
1149
+ } else {
1150
+ await installStoryboardPreset(context, page, screenCase.preset);
1151
+ }
1079
1152
  });
1080
- await atCaptureStage('navigation', () =>
1081
- page.goto(
1082
- resolveStoryboardCaptureUrl(baseUrl, route, screenCase.environment),
1083
- {
1084
- waitUntil: 'domcontentloaded',
1085
- timeout: 30_000,
1086
- ...navigationOptions,
1087
- },
1088
- ),
1089
- );
1153
+ if (!warm) {
1154
+ await atCaptureStage('navigation', () =>
1155
+ page.goto(
1156
+ resolveStoryboardCaptureUrl(baseUrl, route, screenCase.environment),
1157
+ {
1158
+ waitUntil: 'domcontentloaded',
1159
+ timeout: 30_000,
1160
+ ...navigationOptions,
1161
+ },
1162
+ ),
1163
+ );
1164
+ }
1090
1165
  await atCaptureStage('preset', async () => {
1091
1166
  await executeStoryboardPreset(page, screenCase, route);
1092
1167
  await hooks.afterPreset?.({
@@ -1109,7 +1184,10 @@ export async function captureStoryboardCase({
1109
1184
  );
1110
1185
  }
1111
1186
  await atCaptureStage('stabilize', async () => {
1112
- await page.addStyleTag({ content: FROZEN_STYLE });
1187
+ if (!session?.frozen) {
1188
+ await page.addStyleTag({ content: FROZEN_STYLE });
1189
+ if (session) session.frozen = true;
1190
+ }
1113
1191
  const stable = await waitForStableStoryboardDocument(page, stability);
1114
1192
  if (!stable) {
1115
1193
  throw new Error(
@@ -1148,10 +1226,16 @@ export async function captureStoryboardCase({
1148
1226
  }
1149
1227
  }
1150
1228
 
1151
- try {
1152
- await context?.close();
1153
- } catch (error) {
1154
- evidenceErrors.push(new StoryboardCaptureStageError('teardown', error));
1229
+ if (session) {
1230
+ // A failed case may leave the page in a state the next case cannot undo —
1231
+ // the pool discards the lane instead of passing the mess along.
1232
+ if (primaryError) session.poisoned = true;
1233
+ } else {
1234
+ try {
1235
+ await context?.close();
1236
+ } catch (error) {
1237
+ evidenceErrors.push(new StoryboardCaptureStageError('teardown', error));
1238
+ }
1155
1239
  }
1156
1240
 
1157
1241
  if (!primaryError && evidenceErrors.length === 0) {
@@ -7,6 +7,11 @@ export {
7
7
  recommendedStoryboardExecutionConcurrency,
8
8
  } from './storyboard-canonical.mjs';
9
9
 
10
+ export {
11
+ STORYBOARD_SESSION_POOL_LIMITS,
12
+ createStoryboardCaptureSessions,
13
+ } from './storyboard-capture-sessions.mjs';
14
+
10
15
  export {
11
16
  STORYBOARD_CAPTURE_STATUSES,
12
17
  StoryboardCaptureStageError,
@@ -18,6 +23,7 @@ export {
18
23
  hashStoryboardCapture,
19
24
  normalizeStoryboardDomStructure,
20
25
  resolveStoryboardCaptureRoute,
26
+ storyboardCaptureSessionKey,
21
27
  resolveStoryboardCaptureUrl,
22
28
  resolveStoryboardFixedTime,
23
29
  runStoryboardAssertion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/storyboard.d.ts CHANGED
@@ -68,6 +68,34 @@ export interface StoryboardCaptureOptions<
68
68
  screenshotOptions?: Record<string, unknown>;
69
69
  /** How long to wait for a still document, and what may keep moving. */
70
70
  stability?: StoryboardStabilityOptions;
71
+ /** A warm page lane owned by the session pool. Supplied by createStoryboardCaptureSessions. */
72
+ session?: StoryboardCaptureSession | null;
73
+ }
74
+
75
+ /** One booted page plus the group it was booted for. Owned by the session pool. */
76
+ export interface StoryboardCaptureSession {
77
+ key: string;
78
+ context: unknown;
79
+ page: unknown;
80
+ frozen?: boolean;
81
+ poisoned?: boolean;
82
+ }
83
+
84
+ export interface StoryboardCaptureSessionPool<
85
+ TScreenCase extends Record<string, unknown> = Record<string, unknown>,
86
+ > {
87
+ capture(
88
+ screenCase: TScreenCase,
89
+ context?: { isolated?: boolean; [key: string]: unknown },
90
+ ): Promise<StoryboardCaptureResult>;
91
+ stats(): {
92
+ warm: number;
93
+ cold: number;
94
+ boots: number;
95
+ discarded: number;
96
+ lanes: number;
97
+ };
98
+ close(): Promise<void>;
71
99
  }
72
100
 
73
101
  export interface StoryboardStabilityOptions {
@@ -100,6 +128,29 @@ export declare function captureStoryboardCase<
100
128
  >(
101
129
  options: StoryboardCaptureOptions<TScreenCase>,
102
130
  ): Promise<StoryboardCaptureResult>;
131
+ /**
132
+ * Captures declared screens on warm pages, and replayed screens on fresh ones.
133
+ *
134
+ * Boot is the expensive part of a capture, and a declared state does not need
135
+ * one per screen. Cases that replay interactions keep the fresh-context path.
136
+ */
137
+ export declare function createStoryboardCaptureSessions<
138
+ TScreenCase extends Record<string, unknown> = Record<string, unknown>,
139
+ >(
140
+ options: Partial<StoryboardCaptureOptions<TScreenCase>> & {
141
+ capture?: (
142
+ options: StoryboardCaptureOptions<TScreenCase>,
143
+ ) => Promise<StoryboardCaptureResult>;
144
+ maximumLanes?: number;
145
+ },
146
+ ): StoryboardCaptureSessionPool<TScreenCase>;
147
+ export declare const STORYBOARD_SESSION_POOL_LIMITS: {
148
+ readonly maximumLanes: number;
149
+ };
150
+ /** The warm-page group key for a case, or null when the case must boot its own page. */
151
+ export declare function storyboardCaptureSessionKey(
152
+ screenCase: Record<string, unknown>,
153
+ ): string | null;
103
154
  export declare function resolveStoryboardCaptureRoute(
104
155
  screenCase: Record<string, unknown>,
105
156
  ): unknown;
package/types.d.ts CHANGED
@@ -315,6 +315,8 @@ export interface DesignScreenCase extends DesignImportPage {
315
315
  sourcePath?: string;
316
316
  codeName?: string;
317
317
  coverageRoutes?: readonly string[];
318
+ /** Names a group of screens that can be captured on one booted page. */
319
+ session?: string;
318
320
  }
319
321
 
320
322
  export interface DesignBehaviorScenario {