@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.
- package/dist-lib/FrozenRoutePreview-efexOSqR.js +9416 -0
- package/dist-lib/pygmalion.js +17853 -1374
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/component-branches.mjs +108 -0
- package/node/dev-view.vite.mjs +45 -1
- package/node/preview-artifact-plugin.mjs +20 -1
- package/node/preview-artifact-store.mjs +483 -29
- package/node/preview-capture-worker.mjs +529 -0
- package/node/route-dependency-digest.mjs +151 -0
- package/node/route-preview-artifact-v3.mjs +10 -5
- package/node/source-graph.mjs +258 -0
- package/node/storyboard-capture-runtime.mjs +213 -5
- package/node/storyboard-environment.mjs +97 -10
- package/node/vite.mjs +53 -2
- package/package.json +5 -2
- package/types.d.ts +495 -0
- package/vite.d.ts +68 -0
- package/dist-lib/App-CCaXx_KA.js +0 -20441
package/node/source-graph.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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;
|