@pygmalionjs/pygmalion 0.2.26 → 0.2.28
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/node/storyboard-capture-runtime.mjs +124 -40
- package/node/storyboard-capture-sessions.mjs +109 -0
- package/node/storyboard.mjs +6 -0
- package/package.json +2 -1
- package/storyboard.d.ts +51 -0
- package/types.d.ts +2 -0
|
@@ -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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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?.({
|
|
1078
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
captureStoryboardCase,
|
|
3
|
+
storyboardCaptureSessionKey,
|
|
4
|
+
} from './storyboard-capture-runtime.mjs';
|
|
5
|
+
|
|
6
|
+
export const STORYBOARD_SESSION_POOL_LIMITS = Object.freeze({
|
|
7
|
+
maximumLanes: 16,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function assertPositiveLaneLimit(value) {
|
|
11
|
+
const lanes = value ?? STORYBOARD_SESSION_POOL_LIMITS.maximumLanes;
|
|
12
|
+
if (
|
|
13
|
+
!Number.isInteger(lanes) ||
|
|
14
|
+
lanes < 1 ||
|
|
15
|
+
lanes > STORYBOARD_SESSION_POOL_LIMITS.maximumLanes
|
|
16
|
+
) {
|
|
17
|
+
throw new RangeError(
|
|
18
|
+
`Storyboard session lanes must be an integer from 1 to ${STORYBOARD_SESSION_POOL_LIMITS.maximumLanes}.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return lanes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function closeLane(lane) {
|
|
25
|
+
try {
|
|
26
|
+
await lane.context?.close();
|
|
27
|
+
} catch {
|
|
28
|
+
// The lane is being discarded — a close failure has nothing left to protect.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Captures storyboard cases on warm pages when the case declares its state.
|
|
34
|
+
*
|
|
35
|
+
* One booted application can serve many screens: a declared state is reached by
|
|
36
|
+
* switching state, not by loading the app again. Cases that replay interactions
|
|
37
|
+
* keep the fresh-context path, because only the case itself knows how to undo
|
|
38
|
+
* what a click opened.
|
|
39
|
+
*
|
|
40
|
+
* A lane is a booted page plus the key it was booted for. Lanes are leased, so
|
|
41
|
+
* two workers never drive the same page, and a lane that saw a failure is
|
|
42
|
+
* discarded rather than handed to the next case.
|
|
43
|
+
*/
|
|
44
|
+
export function createStoryboardCaptureSessions({
|
|
45
|
+
capture = captureStoryboardCase,
|
|
46
|
+
maximumLanes,
|
|
47
|
+
...defaults
|
|
48
|
+
} = {}) {
|
|
49
|
+
const laneLimit = assertPositiveLaneLimit(maximumLanes);
|
|
50
|
+
const idle = new Map();
|
|
51
|
+
const open = new Set();
|
|
52
|
+
const stats = { warm: 0, cold: 0, boots: 0, discarded: 0 };
|
|
53
|
+
|
|
54
|
+
function leaseLane(key) {
|
|
55
|
+
const available = idle.get(key);
|
|
56
|
+
const lane = available?.pop();
|
|
57
|
+
if (available && available.length === 0) idle.delete(key);
|
|
58
|
+
if (lane) return lane;
|
|
59
|
+
if (open.size >= laneLimit) return null;
|
|
60
|
+
const created = { key, context: null, page: null, frozen: false };
|
|
61
|
+
open.add(created);
|
|
62
|
+
return created;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function releaseLane(lane) {
|
|
66
|
+
if (lane.poisoned || !lane.page) {
|
|
67
|
+
open.delete(lane);
|
|
68
|
+
if (lane.context) stats.discarded += 1;
|
|
69
|
+
await closeLane(lane);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const available = idle.get(lane.key);
|
|
73
|
+
if (available) available.push(lane);
|
|
74
|
+
else idle.set(lane.key, [lane]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async capture(screenCase, context = {}) {
|
|
79
|
+
const key = storyboardCaptureSessionKey(screenCase);
|
|
80
|
+
// Retry passes ask for isolation on purpose — a reused page is exactly
|
|
81
|
+
// what they are trying to rule out as the cause.
|
|
82
|
+
const lane = key && context.isolated !== true ? leaseLane(key) : null;
|
|
83
|
+
if (!lane) {
|
|
84
|
+
stats.cold += 1;
|
|
85
|
+
return capture({ ...defaults, screenCase });
|
|
86
|
+
}
|
|
87
|
+
const booted = Boolean(lane.page);
|
|
88
|
+
if (booted) stats.warm += 1;
|
|
89
|
+
else {
|
|
90
|
+
stats.cold += 1;
|
|
91
|
+
stats.boots += 1;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return await capture({ ...defaults, screenCase, session: lane });
|
|
95
|
+
} finally {
|
|
96
|
+
await releaseLane(lane);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
stats() {
|
|
100
|
+
return { ...stats, lanes: open.size };
|
|
101
|
+
},
|
|
102
|
+
async close() {
|
|
103
|
+
const lanes = [...open];
|
|
104
|
+
open.clear();
|
|
105
|
+
idle.clear();
|
|
106
|
+
await Promise.all(lanes.map((lane) => closeLane(lane)));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/node/storyboard.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.28",
|
|
4
4
|
"description": "Code-backed DOM design sandbox and visual QA editor",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"node/source-operations.mjs",
|
|
47
47
|
"node/storyboard-canonical.mjs",
|
|
48
48
|
"node/storyboard-capture-scheduler.mjs",
|
|
49
|
+
"node/storyboard-capture-sessions.mjs",
|
|
49
50
|
"node/storyboard-capture-runtime.mjs",
|
|
50
51
|
"node/storyboard-environment.mjs",
|
|
51
52
|
"node/storyboard.mjs",
|
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 {
|