@pygmalionjs/pygmalion 0.2.25 → 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.
- package/node/dev-mirror.mjs +102 -3
- package/node/storyboard-capture-runtime.mjs +124 -40
- package/node/storyboard.mjs +6 -0
- package/package.json +1 -1
- package/storyboard.d.ts +51 -0
- package/types.d.ts +2 -0
package/node/dev-mirror.mjs
CHANGED
|
@@ -47,6 +47,74 @@ export function devMirrorRefSlug(ref) {
|
|
|
47
47
|
return createHash('sha256').update(raw).digest('hex').slice(0, 12);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Identity of the project a mirror checkout belongs to.
|
|
52
|
+
*
|
|
53
|
+
* Two worktrees of one repository configure the same mirror path, and each
|
|
54
|
+
* generates its own inventory into it. Sharing one checkout made each server see
|
|
55
|
+
* the other's generated files as unexpected changes and refuse to sync, so a
|
|
56
|
+
* checkout is claimed by the project that syncs it.
|
|
57
|
+
*/
|
|
58
|
+
export function devMirrorOwnerToken(repoRoot, appDirectory = '') {
|
|
59
|
+
const identity = `${path.resolve(repoRoot)}\u0000${appDirectory}`;
|
|
60
|
+
return createHash('sha256').update(identity).digest('hex').slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The claim lives beside the repository's other Pygmalion state, not inside the
|
|
64
|
+
// checkout: a file in the worktree would itself be an unexpected change.
|
|
65
|
+
async function mirrorClaimFile(repoRoot, mirrorRoot) {
|
|
66
|
+
const directory = path.join(await pygmalionStateDirectory(repoRoot), 'mirror-claims');
|
|
67
|
+
const key = createHash('sha256').update(path.resolve(mirrorRoot)).digest('hex').slice(0, 16);
|
|
68
|
+
return path.join(directory, `${key}.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPlainMirrorStamp(value) {
|
|
72
|
+
return (
|
|
73
|
+
value != null &&
|
|
74
|
+
typeof value === 'object' &&
|
|
75
|
+
!Array.isArray(value) &&
|
|
76
|
+
typeof value.owner === 'string' &&
|
|
77
|
+
value.owner.length > 0
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether this project may sync the checkout at `mirrorRoot`.
|
|
83
|
+
*
|
|
84
|
+
* An unclaimed or self-claimed checkout is ours. One claimed by another project is
|
|
85
|
+
* not, and the caller moves to its own path rather than fighting over this one —
|
|
86
|
+
* which is what two worktrees of one repository were doing every time they
|
|
87
|
+
* regenerated their own inventory into the same directory.
|
|
88
|
+
*/
|
|
89
|
+
export async function devMirrorClaim(repoRoot, mirrorRoot, owner) {
|
|
90
|
+
let stamp = null;
|
|
91
|
+
try {
|
|
92
|
+
const raw = await fsp.readFile(await mirrorClaimFile(repoRoot, mirrorRoot), 'utf8');
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
stamp = isPlainMirrorStamp(parsed) ? parsed : null;
|
|
95
|
+
} catch {
|
|
96
|
+
stamp = null;
|
|
97
|
+
}
|
|
98
|
+
if (!stamp) return { owned: true, previousOwner: null };
|
|
99
|
+
return { owned: stamp.owner === owner, previousOwner: stamp.owner };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function claimDevMirror(repoRoot, mirrorRoot, owner) {
|
|
103
|
+
const file = await mirrorClaimFile(repoRoot, mirrorRoot);
|
|
104
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
105
|
+
await fsp
|
|
106
|
+
.writeFile(
|
|
107
|
+
file,
|
|
108
|
+
`${JSON.stringify({
|
|
109
|
+
owner,
|
|
110
|
+
repoRoot: path.resolve(repoRoot),
|
|
111
|
+
mirrorRoot: path.resolve(mirrorRoot),
|
|
112
|
+
claimedAt: new Date().toISOString(),
|
|
113
|
+
})}\n`,
|
|
114
|
+
)
|
|
115
|
+
.catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
50
118
|
/**
|
|
51
119
|
* One checkout per ref. Two editor servers pointed at different refs used to
|
|
52
120
|
* share a single mirror directory and take turns running `git switch` in it, so
|
|
@@ -59,16 +127,17 @@ export function resolveDevMirrorWorktreePaths({
|
|
|
59
127
|
mirrorAppRoot,
|
|
60
128
|
appDirectory = '',
|
|
61
129
|
ref = null,
|
|
130
|
+
owner = null,
|
|
62
131
|
}) {
|
|
63
132
|
const base = path.resolve(mirrorRoot);
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
133
|
+
const parts = [devMirrorRefSlug(ref), owner ? String(owner) : ''].filter(Boolean);
|
|
134
|
+
if (!parts.length) {
|
|
66
135
|
return {
|
|
67
136
|
mirrorRoot: base,
|
|
68
137
|
mirrorAppRoot: path.resolve(mirrorAppRoot ?? path.join(base, appDirectory)),
|
|
69
138
|
};
|
|
70
139
|
}
|
|
71
|
-
const scoped = `${base}-${
|
|
140
|
+
const scoped = `${base}-${parts.join('-')}`;
|
|
72
141
|
return {
|
|
73
142
|
mirrorRoot: scoped,
|
|
74
143
|
mirrorAppRoot: path.resolve(path.join(scoped, appDirectory)),
|
|
@@ -582,6 +651,9 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
582
651
|
);
|
|
583
652
|
// The ref is repointable at runtime and each ref owns its own checkout, so the
|
|
584
653
|
// active paths are recomputed per sync instead of frozen at plugin creation.
|
|
654
|
+
const ownerToken = devMirrorOwnerToken(repoRoot, appDirectory);
|
|
655
|
+
// Set once the base checkout turns out to belong to another project.
|
|
656
|
+
let mirrorOwnerScope = null;
|
|
585
657
|
let mirrorRoot = mirrorBaseRoot;
|
|
586
658
|
let mirrorAppRoot = mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory);
|
|
587
659
|
let viteConfig = viteConfigOverride ?? path.join(mirrorAppRoot, 'vite.config.ts');
|
|
@@ -598,6 +670,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
598
670
|
mirrorAppRoot: mirrorAppOverride,
|
|
599
671
|
appDirectory,
|
|
600
672
|
ref: activeRef,
|
|
673
|
+
owner: mirrorOwnerScope,
|
|
601
674
|
});
|
|
602
675
|
mirrorRoot = resolved.mirrorRoot;
|
|
603
676
|
mirrorAppRoot = resolved.mirrorAppRoot;
|
|
@@ -729,6 +802,24 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
729
802
|
const lockHash = await fileHash(lockFile);
|
|
730
803
|
const stamp = await fsp.readFile(stampFile, 'utf8').catch(() => '');
|
|
731
804
|
if ((await exists(nodeModules)) && stamp.trim() === lockHash) return false;
|
|
805
|
+
// An owner-scoped checkout is a second copy of the same source. Borrowing the
|
|
806
|
+
// base checkout's modules when the lock file agrees keeps that isolation from
|
|
807
|
+
// costing a full install per project.
|
|
808
|
+
if (!(await exists(nodeModules)) && mirrorOwnerScope) {
|
|
809
|
+
const baseModules = path.join(
|
|
810
|
+
mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory),
|
|
811
|
+
dependencies.modulesDirectory ?? 'node_modules',
|
|
812
|
+
);
|
|
813
|
+
const baseLock = path.join(
|
|
814
|
+
mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory),
|
|
815
|
+
dependencies.lockFile ?? 'package-lock.json',
|
|
816
|
+
);
|
|
817
|
+
const baseLockHash = await fileHash(baseLock).catch(() => null);
|
|
818
|
+
if ((await exists(baseModules)) && baseLockHash === lockHash) {
|
|
819
|
+
await fsp.symlink(baseModules, nodeModules, 'dir').catch(() => undefined);
|
|
820
|
+
if (await exists(nodeModules)) return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
732
823
|
await run(
|
|
733
824
|
dependencies.installCommand ?? 'npm',
|
|
734
825
|
dependencies.installArgs ?? ['install', '--no-audit', '--no-fund'],
|
|
@@ -840,6 +931,13 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
840
931
|
if (syncPromise) return syncPromise;
|
|
841
932
|
applyMirrorPathsForRef(ref);
|
|
842
933
|
syncPromise = (async () => {
|
|
934
|
+
// A checkout another project claimed is not ours to regenerate into. Moving
|
|
935
|
+
// to our own path is what ends the tug of war between two worktrees.
|
|
936
|
+
const claim = await devMirrorClaim(repoRoot, mirrorRoot, ownerToken);
|
|
937
|
+
if (!claim.owned && mirrorOwnerScope !== ownerToken) {
|
|
938
|
+
mirrorOwnerScope = ownerToken;
|
|
939
|
+
applyMirrorPathsForRef(ref);
|
|
940
|
+
}
|
|
843
941
|
status = { ...status, state: 'syncing', error: null, warning: null };
|
|
844
942
|
let warning = null;
|
|
845
943
|
try {
|
|
@@ -859,6 +957,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
859
957
|
});
|
|
860
958
|
warning = mirrorState.warning;
|
|
861
959
|
const { previous, commit, shortCommit } = mirrorState;
|
|
960
|
+
await claimDevMirror(repoRoot, mirrorRoot, ownerToken);
|
|
862
961
|
const dependenciesChanged = await syncDependencies();
|
|
863
962
|
await generateInventory(commit);
|
|
864
963
|
await startPreview(dependenciesChanged);
|
|
@@ -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) {
|
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
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 {
|