@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.
- package/README.md +3 -1
- package/dist-lib/{FrozenRoutePreview-DStbZQD4.js → FrozenRoutePreview-X4XRmf9G.js} +2066 -1733
- package/dist-lib/pygmalion.js +7605 -6877
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/canvas/ObjectControls.d.ts +8 -5
- package/dist-lib/types/editor/automaticMotion.d.ts +24 -0
- package/dist-lib/types/editor/automaticPseudoStates.d.ts +21 -0
- package/dist-lib/types/editor/previewBootstrap.d.ts +8 -0
- package/dist-lib/types/editor/projectRuntime.d.ts +5 -0
- package/dist-lib/types/editor/shadowPreview.d.ts +9 -0
- package/dist-lib/types/editor/store.d.ts +22 -0
- package/dist-lib/types/lib.d.ts +1 -1
- package/docs/screen-state-contract.md +46 -1
- package/node/automatic-motion-runtime.mjs +279 -0
- package/node/automatic-pseudo-runtime.mjs +383 -0
- package/node/dev-mirror.mjs +79 -8
- package/node/inspect-plugin.mjs +37 -0
- package/node/storyboard-canonical.mjs +1 -1
- package/node/storyboard-capture-runtime.mjs +36 -7
- package/package.json +3 -1
package/node/dev-mirror.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1091
|
-
PYGMALION_VITE_CONFIG:
|
|
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
|
|
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:
|
|
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,
|
|
1189
|
+
await waitUntilReady(previewPort, appRoot, prefix);
|
|
1119
1190
|
};
|
|
1120
1191
|
|
|
1121
1192
|
const syncMirror = async (nextRef) => {
|
package/node/inspect-plugin.mjs
CHANGED
|
@@ -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\}
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
1487
|
-
|
|
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.
|
|
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",
|