@pygmalionjs/pygmalion 0.2.43 → 0.4.0
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/dist-lib/FrozenRoutePreview-B6xc8Ami.js +8059 -0
- package/dist-lib/pygmalion.js +15183 -1329
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/preview-capture-worker.mjs +529 -0
- package/node/storyboard-capture-runtime.mjs +188 -3
- package/node/vite.mjs +42 -2
- package/package.json +4 -2
- package/vite.d.ts +37 -0
- package/dist-lib/App-CqQXGMGw.js +0 -20573
|
@@ -811,11 +811,35 @@ export async function waitForStableStoryboardDocument(
|
|
|
811
811
|
/**
|
|
812
812
|
* Serializes the current document into an inert, standalone DOM preview.
|
|
813
813
|
*
|
|
814
|
-
*
|
|
814
|
+
* Accepts either the base href string (the legacy form, byte-identical output)
|
|
815
|
+
* or `{ baseHref, sourceComponents }`. When `sourceComponents` — a map of
|
|
816
|
+
* component display name to `{ sourceId }` — is provided, elements that are
|
|
817
|
+
* the first host DOM of a matching component fiber are stamped with
|
|
818
|
+
* `data-pygmalion-source-*` attributes and the body receives the
|
|
819
|
+
* `data-pygmalion-source-stamped="1"` marker (see docs/perf-contracts.md).
|
|
820
|
+
*
|
|
821
|
+
* This function is self-contained because Playwright serializes it into the
|
|
822
|
+
* page: it must reference nothing from module scope. The fiber walk below is
|
|
823
|
+
* therefore a minimal copy of src/editor/fiberMap.ts rather than an import,
|
|
824
|
+
* and component matching is by display name because registry function
|
|
825
|
+
* references never cross into the captured realm.
|
|
815
826
|
*/
|
|
816
827
|
export function serializeStoryboardPreviewDocument(
|
|
817
|
-
|
|
828
|
+
baseHrefOrOptions = '__PYGMALION_PREVIEW_BASE__',
|
|
818
829
|
) {
|
|
830
|
+
const options =
|
|
831
|
+
typeof baseHrefOrOptions === 'string'
|
|
832
|
+
? { baseHref: baseHrefOrOptions }
|
|
833
|
+
: (baseHrefOrOptions ?? {});
|
|
834
|
+
const baseHref =
|
|
835
|
+
typeof options.baseHref === 'string'
|
|
836
|
+
? options.baseHref
|
|
837
|
+
: '__PYGMALION_PREVIEW_BASE__';
|
|
838
|
+
const sourceComponents =
|
|
839
|
+
options.sourceComponents && typeof options.sourceComponents === 'object'
|
|
840
|
+
? options.sourceComponents
|
|
841
|
+
: null;
|
|
842
|
+
|
|
819
843
|
const clone = document.documentElement.cloneNode(true);
|
|
820
844
|
if (!(clone instanceof HTMLElement)) return null;
|
|
821
845
|
|
|
@@ -850,6 +874,162 @@ export function serializeStoryboardPreviewDocument(
|
|
|
850
874
|
});
|
|
851
875
|
});
|
|
852
876
|
|
|
877
|
+
if (sourceComponents) {
|
|
878
|
+
// Source metadata stamping. This must run before canvas replacement and
|
|
879
|
+
// script stripping mutate the clone: both element lists are read from the
|
|
880
|
+
// same still-parallel tree, so index N in the source maps to index N in
|
|
881
|
+
// the clone (the copyFormState pattern above relies on the same fact).
|
|
882
|
+
// Stamping is best-effort — a failure must never cost the snapshot.
|
|
883
|
+
try {
|
|
884
|
+
const findFiber = (el) => {
|
|
885
|
+
for (const key in el) {
|
|
886
|
+
if (key.startsWith('__reactFiber$')) return el[key];
|
|
887
|
+
}
|
|
888
|
+
return null;
|
|
889
|
+
};
|
|
890
|
+
// Strip React.memo (.type) and forwardRef (.render) wrappers, exactly
|
|
891
|
+
// as src/editor/fiberMap.ts unwrapType does.
|
|
892
|
+
const unwrapFiberType = (type) => {
|
|
893
|
+
let current = type;
|
|
894
|
+
while (current && typeof current === 'object') {
|
|
895
|
+
const next = current.type ?? current.render;
|
|
896
|
+
if (next == null) break;
|
|
897
|
+
current = next;
|
|
898
|
+
}
|
|
899
|
+
return current;
|
|
900
|
+
};
|
|
901
|
+
// First host DOM of a component fiber — the instance-root criterion
|
|
902
|
+
// (firstHostElement in src/editor/fiberMap.ts).
|
|
903
|
+
const firstHostElement = (from) => {
|
|
904
|
+
let fiber = from.child;
|
|
905
|
+
while (fiber) {
|
|
906
|
+
const stateNode = fiber.stateNode;
|
|
907
|
+
if (stateNode && stateNode.nodeType === 1) return stateNode;
|
|
908
|
+
if (fiber.child) {
|
|
909
|
+
fiber = fiber.child;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
let node = fiber;
|
|
913
|
+
while (node && node !== from && !node.sibling) node = node.return;
|
|
914
|
+
fiber = node && node !== from ? node.sibling : null;
|
|
915
|
+
}
|
|
916
|
+
return null;
|
|
917
|
+
};
|
|
918
|
+
const resolveInstance = (el) => {
|
|
919
|
+
let hit = null;
|
|
920
|
+
let hitName = '';
|
|
921
|
+
for (let fiber = findFiber(el); fiber; fiber = fiber.return) {
|
|
922
|
+
const type = fiber.type;
|
|
923
|
+
if (type == null || typeof type === 'string') continue;
|
|
924
|
+
const unwrapped = unwrapFiberType(type);
|
|
925
|
+
if (typeof unwrapped !== 'function') continue;
|
|
926
|
+
const name = unwrapped.displayName ?? unwrapped.name;
|
|
927
|
+
if (!name || !sourceComponents[name]) continue;
|
|
928
|
+
// Walk the full return chain so the outermost registered boundary
|
|
929
|
+
// wins when nested matches share a root DOM.
|
|
930
|
+
if (firstHostElement(fiber) === el) {
|
|
931
|
+
hit = fiber;
|
|
932
|
+
hitName = name;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!hit) return null;
|
|
936
|
+
const props = {};
|
|
937
|
+
let childrenText = '';
|
|
938
|
+
for (const [key, value] of Object.entries(hit.memoizedProps ?? {})) {
|
|
939
|
+
if (key === 'children') {
|
|
940
|
+
if (typeof value === 'string') childrenText = value;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (
|
|
944
|
+
typeof value === 'string' ||
|
|
945
|
+
typeof value === 'number' ||
|
|
946
|
+
typeof value === 'boolean'
|
|
947
|
+
) {
|
|
948
|
+
props[key] = value;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
name: hitName,
|
|
953
|
+
sourceId: sourceComponents[hitName].sourceId ?? hitName,
|
|
954
|
+
props,
|
|
955
|
+
childrenText,
|
|
956
|
+
};
|
|
957
|
+
};
|
|
958
|
+
// CSS-module source map of the captured realm, injected by the inspect
|
|
959
|
+
// plugin (mirrors sourceTargetOf in src/editor/inspect.ts).
|
|
960
|
+
const styleByScopedClass = new Map();
|
|
961
|
+
for (const registration of window.__PYG_CSS_MODULES__ ?? []) {
|
|
962
|
+
for (const [localName, scoped] of Object.entries(
|
|
963
|
+
registration.classes ?? {},
|
|
964
|
+
)) {
|
|
965
|
+
if (typeof scoped === 'string' && !styleByScopedClass.has(scoped)) {
|
|
966
|
+
styleByScopedClass.set(
|
|
967
|
+
scoped,
|
|
968
|
+
`${registration.file}#${localName}`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const resolveStyle = (el) => {
|
|
974
|
+
for (const cls of el.classList) {
|
|
975
|
+
const styleSource = styleByScopedClass.get(cls);
|
|
976
|
+
if (styleSource) return styleSource;
|
|
977
|
+
}
|
|
978
|
+
return null;
|
|
979
|
+
};
|
|
980
|
+
const sourceElements = document.documentElement.querySelectorAll('*');
|
|
981
|
+
const cloneElements = clone.querySelectorAll('*');
|
|
982
|
+
const total = Math.min(sourceElements.length, cloneElements.length);
|
|
983
|
+
for (let index = 0; index < total; index += 1) {
|
|
984
|
+
// One element failing to resolve must never break serialization.
|
|
985
|
+
try {
|
|
986
|
+
const sourceElement = sourceElements[index];
|
|
987
|
+
const cloneElement = cloneElements[index];
|
|
988
|
+
const hit = resolveInstance(sourceElement);
|
|
989
|
+
if (hit) {
|
|
990
|
+
cloneElement.setAttribute(
|
|
991
|
+
'data-pygmalion-source-component',
|
|
992
|
+
hit.sourceId,
|
|
993
|
+
);
|
|
994
|
+
cloneElement.setAttribute(
|
|
995
|
+
'data-pygmalion-source-component-name',
|
|
996
|
+
hit.name,
|
|
997
|
+
);
|
|
998
|
+
const propsJson = JSON.stringify(hit.props);
|
|
999
|
+
// Values above a cap are omitted, never truncated mid-JSON.
|
|
1000
|
+
if (propsJson.length <= 8 * 1024) {
|
|
1001
|
+
cloneElement.setAttribute(
|
|
1002
|
+
'data-pygmalion-source-props',
|
|
1003
|
+
propsJson,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
if (hit.childrenText && hit.childrenText.length <= 2 * 1024) {
|
|
1007
|
+
cloneElement.setAttribute(
|
|
1008
|
+
'data-pygmalion-source-children',
|
|
1009
|
+
hit.childrenText,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const styleSource = resolveStyle(sourceElement);
|
|
1014
|
+
if (styleSource) {
|
|
1015
|
+
cloneElement.setAttribute(
|
|
1016
|
+
'data-pygmalion-source-style',
|
|
1017
|
+
styleSource,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
} catch {
|
|
1021
|
+
// Skip the element; stamping is additive metadata.
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const cloneBody = clone.querySelector('body');
|
|
1025
|
+
if (cloneBody) {
|
|
1026
|
+
cloneBody.setAttribute('data-pygmalion-source-stamped', '1');
|
|
1027
|
+
}
|
|
1028
|
+
} catch {
|
|
1029
|
+
// An unstamped snapshot is still a valid snapshot.
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
853
1033
|
const sourceCanvases = [...document.querySelectorAll('canvas')];
|
|
854
1034
|
const cloneCanvases = [...clone.querySelectorAll('canvas')];
|
|
855
1035
|
sourceCanvases.forEach((source, index) => {
|
|
@@ -947,6 +1127,7 @@ async function collectStableEvidence(
|
|
|
947
1127
|
includeDomTree,
|
|
948
1128
|
includePreviewSnapshot,
|
|
949
1129
|
previewBaseToken,
|
|
1130
|
+
sourceComponents = null,
|
|
950
1131
|
screenshotOptions,
|
|
951
1132
|
stability = {},
|
|
952
1133
|
},
|
|
@@ -975,7 +1156,9 @@ async function collectStableEvidence(
|
|
|
975
1156
|
try {
|
|
976
1157
|
const snapshot = await page.evaluate(
|
|
977
1158
|
serializeStoryboardPreviewDocument,
|
|
978
|
-
|
|
1159
|
+
sourceComponents
|
|
1160
|
+
? { baseHref: previewBaseToken, sourceComponents }
|
|
1161
|
+
: previewBaseToken,
|
|
979
1162
|
);
|
|
980
1163
|
if (
|
|
981
1164
|
typeof snapshot !== 'string' ||
|
|
@@ -1061,6 +1244,7 @@ export async function captureStoryboardCase({
|
|
|
1061
1244
|
includeDomTree = true,
|
|
1062
1245
|
includePreviewSnapshot = true,
|
|
1063
1246
|
previewBaseToken = '__PYGMALION_PREVIEW_BASE__',
|
|
1247
|
+
sourceComponents = null,
|
|
1064
1248
|
contextOptions = {},
|
|
1065
1249
|
navigationOptions = {},
|
|
1066
1250
|
screenshotOptions = {},
|
|
@@ -1210,6 +1394,7 @@ export async function captureStoryboardCase({
|
|
|
1210
1394
|
includeDomTree,
|
|
1211
1395
|
includePreviewSnapshot,
|
|
1212
1396
|
previewBaseToken,
|
|
1397
|
+
sourceComponents,
|
|
1213
1398
|
screenshotOptions,
|
|
1214
1399
|
stability,
|
|
1215
1400
|
});
|
package/node/vite.mjs
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
|
|
70
70
|
pygmalionPreviewArtifactPlugin,
|
|
71
71
|
} from './preview-artifact-plugin.mjs';
|
|
72
|
+
import { createPreviewCaptureWorkerChannel } from './preview-capture-worker.mjs';
|
|
72
73
|
import {
|
|
73
74
|
DEFAULT_QA_CAPTURE_MAX_FRAMES,
|
|
74
75
|
PYGMALION_QA_ARTIFACT_PREFIX,
|
|
@@ -217,17 +218,55 @@ export function createPygmalionVitePlugins(config) {
|
|
|
217
218
|
// it lazily because a capture starts long after the plugin list is assembled.
|
|
218
219
|
let devMirror = null;
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
// A host either hands over a generator function or names a warm capture
|
|
222
|
+
// worker script; the function wins when both are present so existing
|
|
223
|
+
// configurations keep their exact behavior.
|
|
224
|
+
let generateArtifact = project.preview.generateArtifact;
|
|
225
|
+
let captureWorkerChannel = null;
|
|
226
|
+
if (!generateArtifact && project.preview.captureWorker) {
|
|
227
|
+
const captureWorker = optionalObject(project.preview.captureWorker);
|
|
228
|
+
if (
|
|
229
|
+
typeof captureWorker.script !== 'string' ||
|
|
230
|
+
captureWorker.script.trim() === ''
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
'Pygmalion preview.captureWorker.script must be a non-empty string',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
captureWorkerChannel = createPreviewCaptureWorkerChannel({
|
|
237
|
+
script: path.resolve(project.appRoot, captureWorker.script),
|
|
238
|
+
args: captureWorker.args,
|
|
239
|
+
env: captureWorker.env,
|
|
240
|
+
cwd: project.appRoot,
|
|
241
|
+
idleMs: captureWorker.idleMs,
|
|
242
|
+
jobTimeoutMs: captureWorker.jobTimeoutMs,
|
|
243
|
+
});
|
|
244
|
+
generateArtifact = captureWorkerChannel.generateArtifact;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (project.preview.artifactFile || generateArtifact) {
|
|
221
248
|
plugins.push(
|
|
222
249
|
pygmalionPreviewArtifactPlugin({
|
|
223
250
|
root: project.appRoot,
|
|
224
251
|
artifactFile: project.preview.artifactFile,
|
|
225
252
|
endpoint: project.preview.artifactEndpoint,
|
|
226
|
-
generateArtifact
|
|
253
|
+
generateArtifact,
|
|
227
254
|
acquireLease: (label) => devMirror?.acquireLease(label) ?? null,
|
|
228
255
|
}),
|
|
229
256
|
);
|
|
230
257
|
}
|
|
258
|
+
if (captureWorkerChannel) {
|
|
259
|
+
const channel = captureWorkerChannel;
|
|
260
|
+
plugins.push({
|
|
261
|
+
name: 'pygmalion-preview-capture-worker',
|
|
262
|
+
apply: 'serve',
|
|
263
|
+
configureServer(server) {
|
|
264
|
+
server.httpServer?.once('close', () => {
|
|
265
|
+
void channel.dispose();
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
231
270
|
|
|
232
271
|
if (
|
|
233
272
|
project.qa !== false &&
|
|
@@ -380,6 +419,7 @@ export {
|
|
|
380
419
|
DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
|
|
381
420
|
PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
|
|
382
421
|
pygmalionPreviewArtifactPlugin,
|
|
422
|
+
createPreviewCaptureWorkerChannel,
|
|
383
423
|
DEFAULT_QA_CAPTURE_MAX_FRAMES,
|
|
384
424
|
PYGMALION_QA_ARTIFACT_PREFIX,
|
|
385
425
|
PYGMALION_QA_CAPTURE_ENDPOINT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pygmalionjs/pygmalion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Code-backed DOM design sandbox and visual QA editor",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"node/inspect-plugin.mjs",
|
|
39
39
|
"node/preview-artifact-plugin.mjs",
|
|
40
40
|
"node/preview-artifact-store.mjs",
|
|
41
|
+
"node/preview-capture-worker.mjs",
|
|
41
42
|
"node/preview-vite-cache.mjs",
|
|
42
43
|
"node/qa-capture-plugin.mjs",
|
|
43
44
|
"node/route-preview-artifact-v2.mjs",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"dev": "vite",
|
|
65
66
|
"build": "tsc && vite build",
|
|
66
67
|
"build:lib": "vite build --config vite.lib.config.ts",
|
|
67
|
-
"test": "node --test node/*.test.mjs src/**/*.test.mjs",
|
|
68
|
+
"test": "node --test node/*.test.mjs src/*.test.mjs src/**/*.test.mjs",
|
|
68
69
|
"lint:language": "node scripts/lint-language.mjs",
|
|
69
70
|
"typecheck": "tsc --noEmit",
|
|
70
71
|
"prepublishOnly": "npm run lint:language && npm test && npm run typecheck && npm run build:lib",
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
"@types/react": "^19.0.0",
|
|
94
95
|
"@types/react-dom": "^19.0.0",
|
|
95
96
|
"@vitejs/plugin-react": "^4.3.0",
|
|
97
|
+
"happy-dom": "^20.11.1",
|
|
96
98
|
"react": "^19.0.0",
|
|
97
99
|
"react-dom": "^19.0.0",
|
|
98
100
|
"tailwindcss": "^4.0.0",
|
package/vite.d.ts
CHANGED
|
@@ -56,6 +56,21 @@ export interface PygmalionSessionConfig {
|
|
|
56
56
|
worktreesRoot?: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export interface PygmalionPreviewCaptureWorkerConfig {
|
|
60
|
+
/**
|
|
61
|
+
* Host-owned warm capture worker script, relative to the application root.
|
|
62
|
+
* The script speaks the preview capture worker protocol (JSON lines over
|
|
63
|
+
* stdio) and keeps its expensive boot warm across artifact requests.
|
|
64
|
+
*/
|
|
65
|
+
script: string;
|
|
66
|
+
args?: readonly string[];
|
|
67
|
+
env?: Record<string, string>;
|
|
68
|
+
/** Idle shutdown delay for the worker process. Default: 300000. */
|
|
69
|
+
idleMs?: number;
|
|
70
|
+
/** Per-job timeout before the worker is killed and restarted. Default: 1200000. */
|
|
71
|
+
jobTimeoutMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
export interface PygmalionPreviewConfig {
|
|
60
75
|
configFile?: string;
|
|
61
76
|
viteConfig?: string;
|
|
@@ -79,6 +94,11 @@ export interface PygmalionPreviewConfig {
|
|
|
79
94
|
frames?: readonly { id: string; fingerprint?: string }[];
|
|
80
95
|
captureBaseUrl?: string;
|
|
81
96
|
}) => unknown | Promise<unknown>;
|
|
97
|
+
/**
|
|
98
|
+
* Warm capture worker used to satisfy artifact generation requests when
|
|
99
|
+
* `generateArtifact` is absent. When both are set, `generateArtifact` wins.
|
|
100
|
+
*/
|
|
101
|
+
captureWorker?: PygmalionPreviewCaptureWorkerConfig;
|
|
82
102
|
}
|
|
83
103
|
|
|
84
104
|
export interface PygmalionQaConfig
|
|
@@ -169,6 +189,23 @@ export declare function pygmalionPreviewArtifactPlugin(options?: {
|
|
|
169
189
|
| null
|
|
170
190
|
| Promise<{ release(): void | Promise<void> } | null>;
|
|
171
191
|
}): PluginOption;
|
|
192
|
+
export interface PygmalionPreviewCaptureWorkerChannel {
|
|
193
|
+
generateArtifact(request: {
|
|
194
|
+
namespace: string;
|
|
195
|
+
sourceRevision: string;
|
|
196
|
+
frames?: readonly { id: string; fingerprint?: string }[];
|
|
197
|
+
captureBaseUrl?: string;
|
|
198
|
+
}): Promise<unknown>;
|
|
199
|
+
dispose(): Promise<void>;
|
|
200
|
+
}
|
|
201
|
+
export declare function createPreviewCaptureWorkerChannel(
|
|
202
|
+
options: PygmalionPreviewCaptureWorkerConfig & {
|
|
203
|
+
cwd?: string;
|
|
204
|
+
maxRestarts?: number;
|
|
205
|
+
restartWindowMs?: number;
|
|
206
|
+
pingTimeoutMs?: number;
|
|
207
|
+
},
|
|
208
|
+
): PygmalionPreviewCaptureWorkerChannel;
|
|
172
209
|
export interface PygmalionGitSourceState {
|
|
173
210
|
commit: string;
|
|
174
211
|
revision: string;
|