@pygmalionjs/pygmalion 0.6.0 → 0.6.2

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.
@@ -37,6 +37,47 @@ export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
37
37
  return path.resolve(inventory?.outputRoot ?? mirrorAppRoot);
38
38
  }
39
39
 
40
+ /** A running preview belongs to one immutable app root for its whole process. */
41
+ export function devPreviewNeedsRestart({
42
+ force = false,
43
+ running = false,
44
+ activeAppRoot = null,
45
+ nextAppRoot,
46
+ }) {
47
+ if (force) return true;
48
+ if (!running) return false;
49
+ if (typeof activeAppRoot !== 'string' || !activeAppRoot.trim()) return true;
50
+ return path.resolve(activeAppRoot) !== path.resolve(nextAppRoot);
51
+ }
52
+
53
+ /**
54
+ * A commit is not ready when the proxy child still serves another ref's root.
55
+ * Returning drifted makes the ordinary boot gate request the same resync that
56
+ * repairs a checkout whose HEAD moved underneath the server.
57
+ */
58
+ export function devMirrorPreviewDriftStatus(
59
+ status,
60
+ { running = false, activeAppRoot = null, expectedAppRoot },
61
+ ) {
62
+ if (status?.state !== 'ready' || typeof expectedAppRoot !== 'string') {
63
+ return status;
64
+ }
65
+ const actual =
66
+ running && typeof activeAppRoot === 'string' && activeAppRoot.trim()
67
+ ? path.resolve(activeAppRoot)
68
+ : null;
69
+ const expected = path.resolve(expectedAppRoot);
70
+ if (actual === expected) return status;
71
+ return {
72
+ ...status,
73
+ state: 'drifted',
74
+ runtimeDrift: { expectedAppRoot: expected, actualAppRoot: actual },
75
+ error: actual
76
+ ? `dev screen runtime drifted: expected ${expected}, serving ${actual}`
77
+ : `dev screen runtime stopped: expected ${expected}`,
78
+ };
79
+ }
80
+
40
81
  /**
41
82
  * Directory-safe form of a source ref, used to give each ref its own checkout.
42
83
  */
@@ -877,6 +918,7 @@ export function pygmalionDevMirrorPlugin(options) {
877
918
  let runtimeRequested = false;
878
919
  let previewChild = null;
879
920
  let previewPort = null;
921
+ let previewAppRoot = null;
880
922
  let syncPromise = null;
881
923
  let sharedLockPathPromise = null;
882
924
  // Assigned once the server is configured. A capture asks for the checkout
@@ -962,6 +1004,18 @@ export function pygmalionDevMirrorPlugin(options) {
962
1004
  */
963
1005
  const verifiedStatus = async () => {
964
1006
  if (status.state !== 'ready' || !status.commit) return status;
1007
+ const runtimeStatus = devMirrorPreviewDriftStatus(status, {
1008
+ running:
1009
+ previewChild != null &&
1010
+ previewChild.exitCode == null &&
1011
+ previewPort != null,
1012
+ activeAppRoot: previewAppRoot,
1013
+ expectedAppRoot: mirrorAppRoot,
1014
+ });
1015
+ if (runtimeStatus !== status) {
1016
+ status = runtimeStatus;
1017
+ return status;
1018
+ }
965
1019
  if (Date.now() - lastVerifiedAt < STATUS_VERIFY_TTL_MS) return status;
966
1020
  lastVerifiedAt = Date.now();
967
1021
  const actual = await git(mirrorRoot, 'rev-parse', 'HEAD').catch(() => null);
@@ -973,6 +1027,7 @@ export function pygmalionDevMirrorPlugin(options) {
973
1027
  const child = previewChild;
974
1028
  previewChild = null;
975
1029
  previewPort = null;
1030
+ previewAppRoot = null;
976
1031
  if (!child || child.exitCode != null) return;
977
1032
  child.kill('SIGTERM');
978
1033
  };
@@ -1052,15 +1107,29 @@ export function pygmalionDevMirrorPlugin(options) {
1052
1107
  await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
1053
1108
  };
1054
1109
 
1055
- const startPreview = async (restart, sourceIdentity = ref) => {
1110
+ const startPreview = async (forceRestart, sourceIdentity = ref) => {
1111
+ const running =
1112
+ previewChild != null &&
1113
+ previewChild.exitCode == null &&
1114
+ previewPort != null;
1115
+ const restart = devPreviewNeedsRestart({
1116
+ force: forceRestart,
1117
+ running,
1118
+ activeAppRoot: previewAppRoot,
1119
+ nextAppRoot: mirrorAppRoot,
1120
+ });
1056
1121
  if (restart) await stopPreview();
1057
1122
  if (previewChild && previewChild.exitCode == null && previewPort != null) return;
1058
1123
 
1124
+ // Keep the process identity local. `mirrorAppRoot` is mutable and can point
1125
+ // at another ref by the time readiness or the exit callback runs.
1126
+ const appRoot = mirrorAppRoot;
1127
+ const appViteConfig = viteConfig;
1059
1128
  previewPort = await pickPort(preferredPreviewPort);
1060
1129
  const viteBin = path.resolve(
1061
1130
  options.viteBin ??
1062
1131
  path.join(
1063
- mirrorAppRoot,
1132
+ appRoot,
1064
1133
  dependencies.modulesDirectory ?? 'node_modules',
1065
1134
  'vite',
1066
1135
  'bin',
@@ -1081,32 +1150,34 @@ export function pygmalionDevMirrorPlugin(options) {
1081
1150
  '--strictPort',
1082
1151
  ],
1083
1152
  {
1084
- cwd: mirrorAppRoot,
1153
+ cwd: appRoot,
1085
1154
  env: {
1086
1155
  ...process.env,
1087
1156
  PYGMALION_PREVIEW_MODE: '1',
1088
1157
  // The child outlives a killed parent otherwise — it watches this pid.
1089
1158
  PYGMALION_PREVIEW_PARENT_PID: String(process.pid),
1090
- PYGMALION_APP_ROOT: mirrorAppRoot,
1091
- PYGMALION_VITE_CONFIG: viteConfig,
1159
+ PYGMALION_APP_ROOT: appRoot,
1160
+ PYGMALION_VITE_CONFIG: appViteConfig,
1092
1161
  PYGMALION_PREVIEW_BASE: `${prefix}/`,
1093
1162
  PYGMALION_VITE_CACHE_DIR: resolvePygmalionPreviewViteCacheDir({
1094
- appRoot: mirrorAppRoot,
1163
+ appRoot,
1095
1164
  instance: `mirror:${sourceIdentity}:${prefix}`,
1096
1165
  modulesDirectory: dependencies.modulesDirectory,
1097
1166
  }),
1098
1167
  // Legacy names keep older preview configs working.
1099
- PYGMALION_DEV_FRONTEND_ROOT: mirrorAppRoot,
1168
+ PYGMALION_DEV_FRONTEND_ROOT: appRoot,
1100
1169
  PYGMALION_DEV_BASE: `${prefix}/`,
1101
1170
  },
1102
1171
  stdio: ['ignore', 'inherit', 'inherit'],
1103
1172
  },
1104
1173
  );
1105
1174
  previewChild = child;
1175
+ previewAppRoot = appRoot;
1106
1176
  child.once('exit', (code) => {
1107
1177
  if (previewChild !== child) return;
1108
1178
  previewChild = null;
1109
1179
  previewPort = null;
1180
+ previewAppRoot = null;
1110
1181
  if (status.state === 'ready' && code !== 0) {
1111
1182
  status = {
1112
1183
  ...status,
@@ -1115,7 +1186,7 @@ export function pygmalionDevMirrorPlugin(options) {
1115
1186
  };
1116
1187
  }
1117
1188
  });
1118
- await waitUntilReady(previewPort, mirrorAppRoot, prefix);
1189
+ await waitUntilReady(previewPort, appRoot, prefix);
1119
1190
  };
1120
1191
 
1121
1192
  const syncMirror = async (nextRef) => {
@@ -45,8 +45,20 @@ const INTERNAL_SOURCE_ATTRIBUTES = new Set([
45
45
  'data-pygmalion-slot-count',
46
46
  'data-pygmalion-own-source',
47
47
  'data-pygmalion-own-props',
48
+ 'data-pygmalion-pseudo-events',
48
49
  ]);
49
50
 
51
+ const HOVER_EVENT_ATTRIBUTES = new Set([
52
+ 'onMouseEnter',
53
+ 'onMouseOver',
54
+ 'onMouseMove',
55
+ 'onPointerEnter',
56
+ 'onPointerOver',
57
+ 'onPointerMove',
58
+ ]);
59
+
60
+ const FOCUS_EVENT_ATTRIBUTES = new Set(['onFocus']);
61
+
50
62
  function primitiveJsxAttributeValue(attribute, sourceFile) {
51
63
  if (!attribute.initializer) return true;
52
64
  if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
@@ -144,6 +156,31 @@ function instrumentJsxInto(code, componentFile, magic) {
144
156
  : 0;
145
157
  injected.push(` data-pygmalion-slot-count="${slotCount}"`);
146
158
  }
159
+ if (
160
+ /^[a-z]/.test(elementName) &&
161
+ !authoredAttributes.has('data-pygmalion-pseudo-events')
162
+ ) {
163
+ const pseudoEvents = [];
164
+ if (
165
+ [...authoredAttributes].some((name) =>
166
+ HOVER_EVENT_ATTRIBUTES.has(name),
167
+ )
168
+ ) {
169
+ pseudoEvents.push('hover');
170
+ }
171
+ if (
172
+ [...authoredAttributes].some((name) =>
173
+ FOCUS_EVENT_ATTRIBUTES.has(name),
174
+ )
175
+ ) {
176
+ pseudoEvents.push('focus');
177
+ }
178
+ if (pseudoEvents.length > 0) {
179
+ injected.push(
180
+ ` data-pygmalion-pseudo-events="${pseudoEvents.join(',')}"`,
181
+ );
182
+ }
183
+ }
147
184
  if (injected.length > 0) {
148
185
  // Insert as first properties. Afterwards, if there are spread/explicit properties, they follow React's general property priority.
149
186
  magic.appendLeft(node.tagName.getEnd(), injected.join(''));
@@ -10,7 +10,7 @@ const AUTO_ID_PATTERN =
10
10
  const TRANSIENT_ATTRIBUTE_PATTERN =
11
11
  /\s(?:data-pygmalion-source|data-pygmalion-slot-count|data-vite-dev-id|data-reactid|data-reactroot|nonce)=(?:"[^"]*"|'[^']*')/gi;
12
12
  const FROZEN_STYLE_PATTERN =
13
- /<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{animation:none!important;transition:none!important;caret-color:transparent!important\}html,body\{pointer-events:none!important\}\s*<\/style>/gi;
13
+ /<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{(?:animation:none|animation-play-state:paused)!important;transition:none!important;caret-color:transparent!important\}(?:html,body\{pointer-events:none!important\})?\s*<\/style>/gi;
14
14
 
15
15
  function safeSnapshot(snapshot) {
16
16
  if (
@@ -1,10 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { annotateStoryboardAutomaticMotionTargets } from './automatic-motion-runtime.mjs';
3
+ import { annotateStoryboardAutomaticPseudoStates } from './automatic-pseudo-runtime.mjs';
2
4
 
3
5
  const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 800 });
4
6
  const QA_FAILURE_STAGES = new Set(['interaction', 'assertion']);
5
7
  const STORYBOARD_ENVIRONMENT_QUERY = '__pygmalion_environment';
6
8
  const FROZEN_STYLE =
7
- '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}';
9
+ '*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}';
8
10
  const DOM_STABLE_ATTRIBUTES = new Set([
9
11
  'type',
10
12
  'name',
@@ -92,6 +94,27 @@ function throwIfAborted(signal) {
92
94
  }
93
95
  }
94
96
 
97
+ function resetStoryboardAnimations() {
98
+ for (const animation of document.getAnimations?.({ subtree: true }) ?? []) {
99
+ try {
100
+ animation.pause();
101
+ animation.currentTime = 0;
102
+ } catch {
103
+ // An animation owned by an unavailable timeline remains CSS-paused.
104
+ }
105
+ }
106
+ }
107
+
108
+ async function freezeStoryboardMotion(page, addStyle = true) {
109
+ if (addStyle) {
110
+ const style = await page.addStyleTag({ content: FROZEN_STYLE });
111
+ await style?.evaluate?.((element) => {
112
+ element.setAttribute('data-pygmalion-preview', 'frozen');
113
+ });
114
+ }
115
+ await page.evaluate(resetStoryboardAnimations);
116
+ }
117
+
95
118
  async function atCaptureStage(stage, task, details = {}) {
96
119
  try {
97
120
  return await task();
@@ -1185,7 +1208,7 @@ export function serializeStoryboardPreviewDocument(
1185
1208
  const frozenStyle = document.createElement('style');
1186
1209
  frozenStyle.setAttribute('data-pygmalion-preview', 'frozen');
1187
1210
  frozenStyle.textContent =
1188
- '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
1211
+ '*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
1189
1212
  head.append(frozenStyle);
1190
1213
 
1191
1214
  return `<!doctype html>${clone.outerHTML}`;
@@ -1242,7 +1265,7 @@ async function collectStableEvidence(
1242
1265
  const evidence = {};
1243
1266
  const errors = [];
1244
1267
  try {
1245
- if (!frozenStyleApplied) await page.addStyleTag({ content: FROZEN_STYLE });
1268
+ if (!frozenStyleApplied) await freezeStoryboardMotion(page);
1246
1269
  if (!alreadyStable) {
1247
1270
  const stable = await waitForStableStoryboardDocument(page, stability);
1248
1271
  if (!stable) {
@@ -1252,6 +1275,14 @@ async function collectStableEvidence(
1252
1275
  } catch (error) {
1253
1276
  errors.push(new StoryboardCaptureStageError('stabilize', error));
1254
1277
  }
1278
+ if (includePreviewSnapshot || includeDomTree) {
1279
+ try {
1280
+ await page.evaluate(annotateStoryboardAutomaticPseudoStates);
1281
+ await page.evaluate(annotateStoryboardAutomaticMotionTargets);
1282
+ } catch (error) {
1283
+ errors.push(new StoryboardCaptureStageError('serialize', error));
1284
+ }
1285
+ }
1255
1286
  if (includeDomTree) {
1256
1287
  try {
1257
1288
  evidence.domTree = await page.evaluate(collectStoryboardDomTree);
@@ -1483,10 +1514,8 @@ export async function captureStoryboardCase({
1483
1514
  );
1484
1515
  }
1485
1516
  await atCaptureStage('stabilize', async () => {
1486
- if (!session?.frozen) {
1487
- await page.addStyleTag({ content: FROZEN_STYLE });
1488
- if (session) session.frozen = true;
1489
- }
1517
+ await freezeStoryboardMotion(page, !session?.frozen);
1518
+ if (session) session.frozen = true;
1490
1519
  frozenStyleApplied = true;
1491
1520
  const stable = await waitForStableStoryboardDocument(page, stability);
1492
1521
  if (!stable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -34,6 +34,8 @@
34
34
  "dist-lib",
35
35
  "docs/screen-state-contract.md",
36
36
  "node/component-branches.mjs",
37
+ "node/automatic-motion-runtime.mjs",
38
+ "node/automatic-pseudo-runtime.mjs",
37
39
  "node/design-session.mjs",
38
40
  "node/dev-mirror.mjs",
39
41
  "node/dev-view.vite.mjs",