@pygmalionjs/pygmalion 0.2.44 → 0.5.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.
@@ -106,6 +106,95 @@ function moduleSpecifiers(source, componentFile) {
106
106
  return [...result].sort();
107
107
  }
108
108
 
109
+ /**
110
+ * Symbol-level import and re-export facts for one file.
111
+ *
112
+ * The dependency graph answers "which files reference this module", which a
113
+ * barrel turns into "the whole app": every atom is imported by
114
+ * `components/ui/index.ts`, and everything imports that. Usage has to be
115
+ * asked about the SYMBOL to mean anything, so this records what each import
116
+ * actually named and what each barrel passes through.
117
+ */
118
+ function moduleSymbols(source, componentFile) {
119
+ const sourceFile = ts.createSourceFile(
120
+ componentFile,
121
+ source,
122
+ ts.ScriptTarget.Latest,
123
+ true,
124
+ scriptKind(componentFile),
125
+ );
126
+ const imports = [];
127
+ const reExports = [];
128
+ const exports = new Set();
129
+
130
+ const visit = (node) => {
131
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
132
+ const clause = node.importClause;
133
+ const names = new Set();
134
+ let namespace = false;
135
+ if (clause?.name) names.add('default');
136
+ const bindings = clause?.namedBindings;
137
+ if (bindings && ts.isNamespaceImport(bindings)) namespace = true;
138
+ if (bindings && ts.isNamedImports(bindings)) {
139
+ for (const element of bindings.elements) {
140
+ if (element.isTypeOnly) continue;
141
+ names.add((element.propertyName ?? element.name).text);
142
+ }
143
+ }
144
+ // A type-only import renders nothing, so it is not usage.
145
+ if (!clause?.isTypeOnly) {
146
+ imports.push({
147
+ specifier: node.moduleSpecifier.text,
148
+ names: [...names],
149
+ namespace,
150
+ });
151
+ }
152
+ } else if (ts.isExportDeclaration(node)) {
153
+ if (node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
154
+ if (node.isTypeOnly) return;
155
+ const clause = node.exportClause;
156
+ if (!clause) {
157
+ reExports.push({ specifier: node.moduleSpecifier.text, star: true, names: [] });
158
+ } else if (ts.isNamespaceExport(clause)) {
159
+ reExports.push({ specifier: node.moduleSpecifier.text, star: true, names: [] });
160
+ exports.add(clause.name.text);
161
+ } else {
162
+ const names = [];
163
+ for (const element of clause.elements) {
164
+ if (element.isTypeOnly) continue;
165
+ // The name importers of this barrel will write.
166
+ names.push(element.name.text);
167
+ exports.add(element.name.text);
168
+ }
169
+ reExports.push({ specifier: node.moduleSpecifier.text, star: false, names });
170
+ }
171
+ } else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
172
+ for (const element of node.exportClause.elements) exports.add(element.name.text);
173
+ }
174
+ } else if (
175
+ (ts.isFunctionDeclaration(node) ||
176
+ ts.isClassDeclaration(node) ||
177
+ ts.isInterfaceDeclaration(node) ||
178
+ ts.isTypeAliasDeclaration(node) ||
179
+ ts.isEnumDeclaration(node)) &&
180
+ node.name &&
181
+ node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
182
+ ) {
183
+ exports.add(node.name.text);
184
+ } else if (
185
+ ts.isVariableStatement(node) &&
186
+ node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
187
+ ) {
188
+ for (const declaration of node.declarationList.declarations) {
189
+ if (ts.isIdentifier(declaration.name)) exports.add(declaration.name.text);
190
+ }
191
+ }
192
+ ts.forEachChild(node, visit);
193
+ };
194
+ visit(sourceFile);
195
+ return { imports, reExports, exports: [...exports].sort() };
196
+ }
197
+
109
198
  function resolveModule(importerFile, specifier, sourceDirectory, files) {
110
199
  if (
111
200
  typeof specifier !== 'string' ||
@@ -189,6 +278,175 @@ export async function buildSourceGraph(root, { sourceDirectory = 'src' } = {}) {
189
278
  };
190
279
  }
191
280
 
281
+ const MAX_BARREL_DEPTH = 6;
282
+ const MAX_USAGE_FILES = 2_000;
283
+
284
+ /**
285
+ * Scans the project once for symbol-level usage.
286
+ *
287
+ * Shares the shape of buildSourceGraph so the same result can answer both
288
+ * "what breaks if this changes" (transitive, over files) and "who uses this"
289
+ * (direct, over symbols).
290
+ */
291
+ export async function buildSourceUsage(root, { sourceDirectory = 'src' } = {}) {
292
+ const location = await sourceLocation(root, sourceDirectory);
293
+ const files = await scanSourceFiles(location.rootPath, location.sourcePath);
294
+ const fileSet = new Set(files);
295
+ const symbols = {};
296
+ const importersByModule = new Map();
297
+ const reExportersByModule = new Map();
298
+ const reverseSets = new Map(files.map((file) => [file, new Set()]));
299
+
300
+ const parsed = [];
301
+ for (let offset = 0; offset < files.length; offset += READ_BATCH_SIZE) {
302
+ parsed.push(
303
+ ...(await Promise.all(
304
+ files.slice(offset, offset + READ_BATCH_SIZE).map(async (componentFile) => {
305
+ const source = await fs.readFile(path.resolve(location.rootPath, componentFile), 'utf8');
306
+ return [componentFile, moduleSymbols(source, componentFile)];
307
+ }),
308
+ )),
309
+ );
310
+ }
311
+
312
+ const index = (map, key, value) => {
313
+ const bucket = map.get(key);
314
+ if (bucket) bucket.push(value);
315
+ else map.set(key, [value]);
316
+ };
317
+
318
+ for (const [componentFile, facts] of parsed) {
319
+ const imports = [];
320
+ const reExports = [];
321
+ for (const entry of facts.imports) {
322
+ const module = resolveModule(
323
+ componentFile,
324
+ entry.specifier,
325
+ location.sourceDirectory,
326
+ fileSet,
327
+ );
328
+ if (!module || module === componentFile) continue;
329
+ const record = { file: componentFile, module, names: entry.names, namespace: entry.namespace };
330
+ imports.push(record);
331
+ index(importersByModule, module, record);
332
+ reverseSets.get(module).add(componentFile);
333
+ }
334
+ for (const entry of facts.reExports) {
335
+ const module = resolveModule(
336
+ componentFile,
337
+ entry.specifier,
338
+ location.sourceDirectory,
339
+ fileSet,
340
+ );
341
+ if (!module || module === componentFile) continue;
342
+ const record = { file: componentFile, module, star: entry.star, names: entry.names };
343
+ reExports.push(record);
344
+ index(reExportersByModule, module, record);
345
+ reverseSets.get(module).add(componentFile);
346
+ }
347
+ symbols[componentFile] = { imports, reExports, exports: facts.exports };
348
+ }
349
+
350
+ const reverseDependencies = {};
351
+ for (const componentFile of files) {
352
+ reverseDependencies[componentFile] = [...reverseSets.get(componentFile)].sort();
353
+ }
354
+
355
+ return {
356
+ sourceDirectory: location.sourceDirectory,
357
+ files,
358
+ symbols,
359
+ reverseDependencies,
360
+ importersByModule,
361
+ reExportersByModule,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Files that use a module's symbols, with barrels made transparent.
367
+ *
368
+ * A file that only re-exports is not a user — it is a pass-through, so the
369
+ * search continues to whoever imported the name from it. An importer counts
370
+ * when it names one of the symbols that reached that barrel from the target,
371
+ * or takes the namespace.
372
+ */
373
+ export function directSourceUsers(usage, targetFile, { maxDepth = MAX_BARREL_DEPTH } = {}) {
374
+ if (!usage?.symbols || !usage.symbols[targetFile]) {
375
+ return { users: [], barrels: [] };
376
+ }
377
+ const users = new Set();
378
+ const barrels = new Set();
379
+ const seen = new Set();
380
+ // 'star' means every symbol of the target is in play, which is what the
381
+ // target itself exposes to a direct importer.
382
+ const queue = [{ module: targetFile, names: 'star', depth: 0 }];
383
+
384
+ while (queue.length > 0) {
385
+ const { module, names, depth } = queue.shift();
386
+ const key = `${module}::${names === 'star' ? '*' : [...names].sort().join(',')}`;
387
+ if (seen.has(key)) continue;
388
+ seen.add(key);
389
+
390
+ for (const record of usage.importersByModule.get(module) ?? []) {
391
+ if (users.size >= MAX_USAGE_FILES) break;
392
+ const matches =
393
+ record.namespace ||
394
+ names === 'star' ||
395
+ record.names.some((name) => names.has(name));
396
+ if (matches) users.add(record.file);
397
+ }
398
+
399
+ if (depth >= maxDepth) continue;
400
+ for (const record of usage.reExportersByModule.get(module) ?? []) {
401
+ let exposed;
402
+ if (record.star) {
403
+ // `export *` forwards the target's own names — not everything the
404
+ // barrel has. Treating it as "everything" made a file that imported
405
+ // one atom look like a user of every atom beside it.
406
+ const moduleExports = usage.symbols[module]?.exports ?? [];
407
+ if (moduleExports.length === 0) {
408
+ // Nothing parsed to narrow by (a nested star chain); keep reach
409
+ // rather than silently dropping users.
410
+ exposed = names;
411
+ } else if (names === 'star') {
412
+ exposed = new Set(moduleExports);
413
+ } else {
414
+ exposed = new Set([...names].filter((name) => moduleExports.includes(name)));
415
+ }
416
+ } else {
417
+ exposed =
418
+ names === 'star'
419
+ ? new Set(record.names)
420
+ : new Set(record.names.filter((name) => names.has(name)));
421
+ }
422
+ if (exposed !== 'star' && exposed.size === 0) continue;
423
+ barrels.add(record.file);
424
+ queue.push({ module: record.file, names: exposed, depth: depth + 1 });
425
+ }
426
+ }
427
+
428
+ // A pure pass-through is not a user of what it forwards.
429
+ for (const barrel of barrels) users.delete(barrel);
430
+ return { users: [...users].sort(), barrels: [...barrels].sort() };
431
+ }
432
+
433
+ /** Direct users plus the transitive file count, per requested file. */
434
+ export async function findSourceUsage(root, componentFiles, { sourceDirectory = 'src' } = {}) {
435
+ const usage = await buildSourceUsage(root, { sourceDirectory });
436
+ const requested = validateComponentFiles(componentFiles, usage.sourceDirectory);
437
+ const files = new Set(usage.files);
438
+ const result = {};
439
+ for (const componentFile of requested) {
440
+ if (!files.has(componentFile)) continue;
441
+ const { users, barrels } = directSourceUsers(usage, componentFile);
442
+ const transitive = collectAffectedSourceFiles(usage, [componentFile]).filter(
443
+ (file) => file !== componentFile,
444
+ );
445
+ result[componentFile] = { direct: users, barrels, transitive: transitive.length };
446
+ }
447
+ return result;
448
+ }
449
+
192
450
  function validateComponentFilePath(value, sourceDirectory) {
193
451
  if (
194
452
  typeof value !== 'string' ||
@@ -38,6 +38,15 @@ export const STORYBOARD_CAPTURE_STATUSES = Object.freeze({
38
38
  captureError: 'capture-error',
39
39
  });
40
40
 
41
+ /**
42
+ * Device pixels captured per CSS pixel. Screenshots are the zoomable canvas
43
+ * surface, so they are taken above 1:1 — a 1x bitmap reads as a blurry
44
+ * screenshot on high-density displays the moment the camera zooms in.
45
+ * Mirrored in the capture recipe identity (`previewBootstrap.ts`) so changing
46
+ * it retires every previously captured artifact.
47
+ */
48
+ export const STORYBOARD_CAPTURE_SCREENSHOT_SCALE = 2;
49
+
41
50
  export class StoryboardCaptureStageError extends Error {
42
51
  constructor(stage, error, details = {}) {
43
52
  const message = error instanceof Error ? error.message : String(error);
@@ -811,11 +820,35 @@ export async function waitForStableStoryboardDocument(
811
820
  /**
812
821
  * Serializes the current document into an inert, standalone DOM preview.
813
822
  *
814
- * This function is self-contained because Playwright serializes it into the page.
823
+ * Accepts either the base href string (the legacy form, byte-identical output)
824
+ * or `{ baseHref, sourceComponents }`. When `sourceComponents` — a map of
825
+ * component display name to `{ sourceId }` — is provided, elements that are
826
+ * the first host DOM of a matching component fiber are stamped with
827
+ * `data-pygmalion-source-*` attributes and the body receives the
828
+ * `data-pygmalion-source-stamped="1"` marker (see docs/perf-contracts.md).
829
+ *
830
+ * This function is self-contained because Playwright serializes it into the
831
+ * page: it must reference nothing from module scope. The fiber walk below is
832
+ * therefore a minimal copy of src/editor/fiberMap.ts rather than an import,
833
+ * and component matching is by display name because registry function
834
+ * references never cross into the captured realm.
815
835
  */
816
836
  export function serializeStoryboardPreviewDocument(
817
- baseHref = '__PYGMALION_PREVIEW_BASE__',
837
+ baseHrefOrOptions = '__PYGMALION_PREVIEW_BASE__',
818
838
  ) {
839
+ const options =
840
+ typeof baseHrefOrOptions === 'string'
841
+ ? { baseHref: baseHrefOrOptions }
842
+ : (baseHrefOrOptions ?? {});
843
+ const baseHref =
844
+ typeof options.baseHref === 'string'
845
+ ? options.baseHref
846
+ : '__PYGMALION_PREVIEW_BASE__';
847
+ const sourceComponents =
848
+ options.sourceComponents && typeof options.sourceComponents === 'object'
849
+ ? options.sourceComponents
850
+ : null;
851
+
819
852
  const clone = document.documentElement.cloneNode(true);
820
853
  if (!(clone instanceof HTMLElement)) return null;
821
854
 
@@ -850,6 +883,162 @@ export function serializeStoryboardPreviewDocument(
850
883
  });
851
884
  });
852
885
 
886
+ if (sourceComponents) {
887
+ // Source metadata stamping. This must run before canvas replacement and
888
+ // script stripping mutate the clone: both element lists are read from the
889
+ // same still-parallel tree, so index N in the source maps to index N in
890
+ // the clone (the copyFormState pattern above relies on the same fact).
891
+ // Stamping is best-effort — a failure must never cost the snapshot.
892
+ try {
893
+ const findFiber = (el) => {
894
+ for (const key in el) {
895
+ if (key.startsWith('__reactFiber$')) return el[key];
896
+ }
897
+ return null;
898
+ };
899
+ // Strip React.memo (.type) and forwardRef (.render) wrappers, exactly
900
+ // as src/editor/fiberMap.ts unwrapType does.
901
+ const unwrapFiberType = (type) => {
902
+ let current = type;
903
+ while (current && typeof current === 'object') {
904
+ const next = current.type ?? current.render;
905
+ if (next == null) break;
906
+ current = next;
907
+ }
908
+ return current;
909
+ };
910
+ // First host DOM of a component fiber — the instance-root criterion
911
+ // (firstHostElement in src/editor/fiberMap.ts).
912
+ const firstHostElement = (from) => {
913
+ let fiber = from.child;
914
+ while (fiber) {
915
+ const stateNode = fiber.stateNode;
916
+ if (stateNode && stateNode.nodeType === 1) return stateNode;
917
+ if (fiber.child) {
918
+ fiber = fiber.child;
919
+ continue;
920
+ }
921
+ let node = fiber;
922
+ while (node && node !== from && !node.sibling) node = node.return;
923
+ fiber = node && node !== from ? node.sibling : null;
924
+ }
925
+ return null;
926
+ };
927
+ const resolveInstance = (el) => {
928
+ let hit = null;
929
+ let hitName = '';
930
+ for (let fiber = findFiber(el); fiber; fiber = fiber.return) {
931
+ const type = fiber.type;
932
+ if (type == null || typeof type === 'string') continue;
933
+ const unwrapped = unwrapFiberType(type);
934
+ if (typeof unwrapped !== 'function') continue;
935
+ const name = unwrapped.displayName ?? unwrapped.name;
936
+ if (!name || !sourceComponents[name]) continue;
937
+ // Walk the full return chain so the outermost registered boundary
938
+ // wins when nested matches share a root DOM.
939
+ if (firstHostElement(fiber) === el) {
940
+ hit = fiber;
941
+ hitName = name;
942
+ }
943
+ }
944
+ if (!hit) return null;
945
+ const props = {};
946
+ let childrenText = '';
947
+ for (const [key, value] of Object.entries(hit.memoizedProps ?? {})) {
948
+ if (key === 'children') {
949
+ if (typeof value === 'string') childrenText = value;
950
+ continue;
951
+ }
952
+ if (
953
+ typeof value === 'string' ||
954
+ typeof value === 'number' ||
955
+ typeof value === 'boolean'
956
+ ) {
957
+ props[key] = value;
958
+ }
959
+ }
960
+ return {
961
+ name: hitName,
962
+ sourceId: sourceComponents[hitName].sourceId ?? hitName,
963
+ props,
964
+ childrenText,
965
+ };
966
+ };
967
+ // CSS-module source map of the captured realm, injected by the inspect
968
+ // plugin (mirrors sourceTargetOf in src/editor/inspect.ts).
969
+ const styleByScopedClass = new Map();
970
+ for (const registration of window.__PYG_CSS_MODULES__ ?? []) {
971
+ for (const [localName, scoped] of Object.entries(
972
+ registration.classes ?? {},
973
+ )) {
974
+ if (typeof scoped === 'string' && !styleByScopedClass.has(scoped)) {
975
+ styleByScopedClass.set(
976
+ scoped,
977
+ `${registration.file}#${localName}`,
978
+ );
979
+ }
980
+ }
981
+ }
982
+ const resolveStyle = (el) => {
983
+ for (const cls of el.classList) {
984
+ const styleSource = styleByScopedClass.get(cls);
985
+ if (styleSource) return styleSource;
986
+ }
987
+ return null;
988
+ };
989
+ const sourceElements = document.documentElement.querySelectorAll('*');
990
+ const cloneElements = clone.querySelectorAll('*');
991
+ const total = Math.min(sourceElements.length, cloneElements.length);
992
+ for (let index = 0; index < total; index += 1) {
993
+ // One element failing to resolve must never break serialization.
994
+ try {
995
+ const sourceElement = sourceElements[index];
996
+ const cloneElement = cloneElements[index];
997
+ const hit = resolveInstance(sourceElement);
998
+ if (hit) {
999
+ cloneElement.setAttribute(
1000
+ 'data-pygmalion-source-component',
1001
+ hit.sourceId,
1002
+ );
1003
+ cloneElement.setAttribute(
1004
+ 'data-pygmalion-source-component-name',
1005
+ hit.name,
1006
+ );
1007
+ const propsJson = JSON.stringify(hit.props);
1008
+ // Values above a cap are omitted, never truncated mid-JSON.
1009
+ if (propsJson.length <= 8 * 1024) {
1010
+ cloneElement.setAttribute(
1011
+ 'data-pygmalion-source-props',
1012
+ propsJson,
1013
+ );
1014
+ }
1015
+ if (hit.childrenText && hit.childrenText.length <= 2 * 1024) {
1016
+ cloneElement.setAttribute(
1017
+ 'data-pygmalion-source-children',
1018
+ hit.childrenText,
1019
+ );
1020
+ }
1021
+ }
1022
+ const styleSource = resolveStyle(sourceElement);
1023
+ if (styleSource) {
1024
+ cloneElement.setAttribute(
1025
+ 'data-pygmalion-source-style',
1026
+ styleSource,
1027
+ );
1028
+ }
1029
+ } catch {
1030
+ // Skip the element; stamping is additive metadata.
1031
+ }
1032
+ }
1033
+ const cloneBody = clone.querySelector('body');
1034
+ if (cloneBody) {
1035
+ cloneBody.setAttribute('data-pygmalion-source-stamped', '1');
1036
+ }
1037
+ } catch {
1038
+ // An unstamped snapshot is still a valid snapshot.
1039
+ }
1040
+ }
1041
+
853
1042
  const sourceCanvases = [...document.querySelectorAll('canvas')];
854
1043
  const cloneCanvases = [...clone.querySelectorAll('canvas')];
855
1044
  sourceCanvases.forEach((source, index) => {
@@ -940,6 +1129,14 @@ function captureDiagnostic(error, viewport) {
940
1129
  };
941
1130
  }
942
1131
 
1132
+ /** The context option wins so a caller's override keeps honest metadata. */
1133
+ function resolveScreenshotScale(contextOptions = {}) {
1134
+ const scale = contextOptions.deviceScaleFactor;
1135
+ return Number.isFinite(scale) && scale > 0
1136
+ ? scale
1137
+ : STORYBOARD_CAPTURE_SCREENSHOT_SCALE;
1138
+ }
1139
+
943
1140
  async function collectStableEvidence(
944
1141
  page,
945
1142
  viewport,
@@ -947,7 +1144,9 @@ async function collectStableEvidence(
947
1144
  includeDomTree,
948
1145
  includePreviewSnapshot,
949
1146
  previewBaseToken,
1147
+ sourceComponents = null,
950
1148
  screenshotOptions,
1149
+ screenshotScale = STORYBOARD_CAPTURE_SCREENSHOT_SCALE,
951
1150
  stability = {},
952
1151
  },
953
1152
  ) {
@@ -975,7 +1174,9 @@ async function collectStableEvidence(
975
1174
  try {
976
1175
  const snapshot = await page.evaluate(
977
1176
  serializeStoryboardPreviewDocument,
978
- previewBaseToken,
1177
+ sourceComponents
1178
+ ? { baseHref: previewBaseToken, sourceComponents }
1179
+ : previewBaseToken,
979
1180
  );
980
1181
  if (
981
1182
  typeof snapshot !== 'string' ||
@@ -989,15 +1190,18 @@ async function collectStableEvidence(
989
1190
  }
990
1191
  }
991
1192
  try {
1193
+ // Screenshot metadata records the bitmap's pixel dimensions; the frame's
1194
+ // CSS-pixel geometry stays on the capture record's `viewport`.
992
1195
  evidence.screenshot = {
993
1196
  mediaType: 'image/png',
994
- width: viewport.width,
995
- height: viewport.height,
1197
+ width: viewport.width * screenshotScale,
1198
+ height: viewport.height * screenshotScale,
996
1199
  bytes: await page.screenshot({
997
1200
  type: 'png',
998
1201
  fullPage: false,
999
1202
  animations: 'disabled',
1000
1203
  caret: 'hide',
1204
+ scale: 'device',
1001
1205
  ...screenshotOptions,
1002
1206
  }),
1003
1207
  };
@@ -1061,6 +1265,7 @@ export async function captureStoryboardCase({
1061
1265
  includeDomTree = true,
1062
1266
  includePreviewSnapshot = true,
1063
1267
  previewBaseToken = '__PYGMALION_PREVIEW_BASE__',
1268
+ sourceComponents = null,
1064
1269
  contextOptions = {},
1065
1270
  navigationOptions = {},
1066
1271
  screenshotOptions = {},
@@ -1107,6 +1312,7 @@ export async function captureStoryboardCase({
1107
1312
  context = await atCaptureStage('setup', () =>
1108
1313
  browser.newContext({
1109
1314
  viewport,
1315
+ deviceScaleFactor: resolveScreenshotScale(contextOptions),
1110
1316
  colorScheme: 'light',
1111
1317
  reducedMotion: 'reduce',
1112
1318
  locale: 'en-US',
@@ -1210,7 +1416,9 @@ export async function captureStoryboardCase({
1210
1416
  includeDomTree,
1211
1417
  includePreviewSnapshot,
1212
1418
  previewBaseToken,
1419
+ sourceComponents,
1213
1420
  screenshotOptions,
1421
+ screenshotScale: resolveScreenshotScale(contextOptions),
1214
1422
  stability,
1215
1423
  });
1216
1424
  evidence = collected.evidence;