@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.
@@ -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
- * This function is self-contained because Playwright serializes it into the page.
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
- baseHref = '__PYGMALION_PREVIEW_BASE__',
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
- previewBaseToken,
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
- if (project.preview.artifactFile || project.preview.generateArtifact) {
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: project.preview.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.2.43",
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;