@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.
@@ -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 slug = devMirrorRefSlug(ref);
65
- if (!slug) {
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}-${slug}`;
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
- 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.25",
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 {