@invarn/cibuild 2.0.3 → 2.0.5
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IACnC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C,mFAAmF;AACnF,eAAO,MAAM,aAAa,IAAI,CAAC;AAE/B,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D,sEAAsE;AACtE,eAAO,MAAM,sBAAsB,EAAE,aAAsB,CAAC;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,QAAmB,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAWzD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAQzD;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,yEAAyE;IACzE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED,4DAA4D;AAC5D,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IACzF,kBAAkB,CAAC,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;IAC7D,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAuB,GAAG,MAAM,CAAC;CAC/E;
|
|
1
|
+
{"version":3,"file":"ui-fidelity-render.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render.ts"],"names":[],"mappings":"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"}
|
|
@@ -910,29 +910,17 @@ function findAssetCatalogs(root) {
|
|
|
910
910
|
return found.sort();
|
|
911
911
|
}
|
|
912
912
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
var binResult = runSwift(['build', '--package-path', harnessDir, '--show-bin-path']);
|
|
918
|
-
if (binResult.status !== 0) {
|
|
919
|
-
logError('asset-catalog compile skipped: could not resolve the build bin path');
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
var binPath = String(binResult.stdout || '').trim().split('\n').pop().trim();
|
|
923
|
-
if (!binPath) {
|
|
924
|
-
logError('asset-catalog compile skipped: empty build bin path');
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
913
|
+
// Run actool over the given catalogs, emitting Assets.car into outDir.
|
|
914
|
+
// Best-effort: a failure is logged (warn-and-continue) and rendering proceeds.
|
|
915
|
+
function compileCatalogsInto(catalogs, outDir, label) {
|
|
927
916
|
try {
|
|
928
|
-
fs.mkdirSync(
|
|
917
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
929
918
|
} catch (mkdirError) {
|
|
930
919
|
// best effort -- actool reports a missing output dir itself
|
|
931
920
|
}
|
|
932
|
-
|
|
933
921
|
var args = ['actool'].concat(catalogs);
|
|
934
922
|
args.push(
|
|
935
|
-
'--compile',
|
|
923
|
+
'--compile', outDir,
|
|
936
924
|
'--platform', 'macosx',
|
|
937
925
|
'--minimum-deployment-target', ASSET_CATALOG_MIN_MACOS,
|
|
938
926
|
'--output-format', 'human-readable-text'
|
|
@@ -948,12 +936,245 @@ function compileAssetCatalogs(packageDir, harnessDir) {
|
|
|
948
936
|
var detail = (result.stderr || '') +
|
|
949
937
|
(result.error ? String(result.error.message || result.error) : '');
|
|
950
938
|
logError(
|
|
951
|
-
'asset-catalog compile failed (continuing; catalog
|
|
952
|
-
'resolve): ' + sanitizeMessage(detail.trim())
|
|
939
|
+
'asset-catalog compile failed for ' + label + ' (continuing; catalog ' +
|
|
940
|
+
'images may not resolve): ' + sanitizeMessage(detail.trim())
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// SwiftUI resolves Image("name") against Bundle.main and
|
|
946
|
+
// Image("name", bundle: .module) against a target's own resource bundle.
|
|
947
|
+
// swift build copies a .process'd .xcassets into the module bundle but never
|
|
948
|
+
// compiles it, and a loose catalog is copied nowhere. So compile twice:
|
|
949
|
+
// the package's source catalogs into the bin dir (= Bundle.main), AND each
|
|
950
|
+
// module bundle's copied catalog in place (= Bundle.module). This covers both
|
|
951
|
+
// reference idioms, so the agent need not know which bundle the renderer uses.
|
|
952
|
+
function compileAssetCatalogs(packageDir, harnessDir) {
|
|
953
|
+
var sourceCatalogs = findAssetCatalogs(packageDir);
|
|
954
|
+
if (sourceCatalogs.length === 0) return;
|
|
955
|
+
|
|
956
|
+
var binResult = runSwift(['build', '--package-path', harnessDir, '--show-bin-path']);
|
|
957
|
+
if (binResult.status !== 0) {
|
|
958
|
+
logError('asset-catalog compile skipped: could not resolve the build bin path');
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
var binPath = String(binResult.stdout || '').trim().split('\n').pop().trim();
|
|
962
|
+
if (!binPath) {
|
|
963
|
+
logError('asset-catalog compile skipped: empty build bin path');
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Bundle.main: the package's source catalogs, merged into the bin dir.
|
|
968
|
+
compileCatalogsInto(sourceCatalogs, binPath, 'Bundle.main');
|
|
969
|
+
|
|
970
|
+
// Bundle.module: each *.bundle in the bin dir that carries a copied catalog,
|
|
971
|
+
// compiled in place so Image(..., bundle: .module) resolves.
|
|
972
|
+
var entries;
|
|
973
|
+
try {
|
|
974
|
+
entries = fs.readdirSync(binPath, { withFileTypes: true });
|
|
975
|
+
} catch (readError) {
|
|
976
|
+
entries = [];
|
|
977
|
+
}
|
|
978
|
+
for (var i = 0; i < entries.length; i++) {
|
|
979
|
+
var entry = entries[i];
|
|
980
|
+
if (!entry.isDirectory() || entry.name.slice(-7) !== '.bundle') continue;
|
|
981
|
+
var bundleDir = path.join(binPath, entry.name);
|
|
982
|
+
var bundleCatalogs = findAssetCatalogs(bundleDir);
|
|
983
|
+
if (bundleCatalogs.length > 0) {
|
|
984
|
+
compileCatalogsInto(bundleCatalogs, bundleDir, entry.name);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
`;
|
|
989
|
+
/**
|
|
990
|
+
* Runtime support for trimming the rendered output (package_source variants).
|
|
991
|
+
*
|
|
992
|
+
* The render entry forces the view into a fixed device-sized frame, so a
|
|
993
|
+
* component smaller than the canvas renders with transparent margins. After a
|
|
994
|
+
* screen renders, crop the PNG in place to the bounding box of its
|
|
995
|
+
* non-transparent pixels, so the rendered artifact is the component itself,
|
|
996
|
+
* tight -- no canvas, no background ambiguity for the human comparing screens.
|
|
997
|
+
*
|
|
998
|
+
* The crop is done by a small CoreGraphics helper run in Swift's
|
|
999
|
+
* script-interpreter mode (so it needs no extra harness target and reuses the
|
|
1000
|
+
* one `swift` the runner already has). Best-effort: a trim failure is logged
|
|
1001
|
+
* (warn-and-continue) and the untrimmed render is kept -- a cosmetic post-step
|
|
1002
|
+
* never fails an otherwise good render. This is prep, not scoring: it produces
|
|
1003
|
+
* a tighter image for the human's judgment and computes nothing about
|
|
1004
|
+
* fidelity.
|
|
1005
|
+
*/
|
|
1006
|
+
const RENDER_SCRIPT_TRIM_STAGE = String.raw `
|
|
1007
|
+
// ---- render trimming stage (package_source variants) ----
|
|
1008
|
+
|
|
1009
|
+
// CoreGraphics helper, run via 'swift <Trim.swift> <in.png> <out.png>'
|
|
1010
|
+
// (script-interpreter mode). Crops a PNG to the bounding box of its
|
|
1011
|
+
// non-transparent pixels and writes it to the output path, which may equal the
|
|
1012
|
+
// input path (in-place trim).
|
|
1013
|
+
var TRIM_HELPER_SWIFT = [
|
|
1014
|
+
'import Foundation',
|
|
1015
|
+
'import ImageIO',
|
|
1016
|
+
'import CoreGraphics',
|
|
1017
|
+
'import UniformTypeIdentifiers',
|
|
1018
|
+
'',
|
|
1019
|
+
'func loadCGImage(_ path: String) -> CGImage? {',
|
|
1020
|
+
' guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),',
|
|
1021
|
+
' let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { return nil }',
|
|
1022
|
+
' return img',
|
|
1023
|
+
'}',
|
|
1024
|
+
'',
|
|
1025
|
+
'func writePNG(_ image: CGImage, to path: String) -> Bool {',
|
|
1026
|
+
' guard let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else { return false }',
|
|
1027
|
+
' CGImageDestinationAddImage(dest, image, nil)',
|
|
1028
|
+
' return CGImageDestinationFinalize(dest)',
|
|
1029
|
+
'}',
|
|
1030
|
+
'',
|
|
1031
|
+
'// Redraw an image at an exact pixel size (used to match the reference).',
|
|
1032
|
+
'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
|
|
1033
|
+
' if w <= 0 || h <= 0 { return nil }',
|
|
1034
|
+
' let bytesPerRow = w * 4',
|
|
1035
|
+
' let space = CGColorSpaceCreateDeviceRGB()',
|
|
1036
|
+
' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
|
|
1037
|
+
' ctx.interpolationQuality = .high',
|
|
1038
|
+
' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
|
|
1039
|
+
' return ctx.makeImage()',
|
|
1040
|
+
'}',
|
|
1041
|
+
'',
|
|
1042
|
+
'// Bounding box of pixels with non-zero alpha, in top-left image coordinates.',
|
|
1043
|
+
'func alphaBoundingBox(_ image: CGImage) -> CGRect? {',
|
|
1044
|
+
' let w = image.width, h = image.height',
|
|
1045
|
+
' if w == 0 || h == 0 { return nil }',
|
|
1046
|
+
' let bytesPerRow = w * 4',
|
|
1047
|
+
' var data = [UInt8](repeating: 0, count: bytesPerRow * h)',
|
|
1048
|
+
' let space = CGColorSpaceCreateDeviceRGB()',
|
|
1049
|
+
' guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
|
|
1050
|
+
' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
|
|
1051
|
+
' var minX = w, minY = h, maxX = -1, maxY = -1',
|
|
1052
|
+
' for y in 0..<h {',
|
|
1053
|
+
' let rowBase = y * bytesPerRow',
|
|
1054
|
+
' for x in 0..<w {',
|
|
1055
|
+
' if data[rowBase + x * 4 + 3] > 0 {',
|
|
1056
|
+
' if x < minX { minX = x }',
|
|
1057
|
+
' if x > maxX { maxX = x }',
|
|
1058
|
+
' if y < minY { minY = y }',
|
|
1059
|
+
' if y > maxY { maxY = y }',
|
|
1060
|
+
' }',
|
|
1061
|
+
' }',
|
|
1062
|
+
' }',
|
|
1063
|
+
' if maxX < 0 { return nil }',
|
|
1064
|
+
' return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)',
|
|
1065
|
+
'}',
|
|
1066
|
+
'',
|
|
1067
|
+
'let arguments = CommandLine.arguments',
|
|
1068
|
+
'guard arguments.count >= 3 else {',
|
|
1069
|
+
' FileHandle.standardError.write(Data("usage: Trim <in.png> <out.png>".utf8))',
|
|
1070
|
+
' exit(64)',
|
|
1071
|
+
'}',
|
|
1072
|
+
'let inputPath = arguments[1]',
|
|
1073
|
+
'let outputPath = arguments[2]',
|
|
1074
|
+
'guard let image = loadCGImage(inputPath) else {',
|
|
1075
|
+
' FileHandle.standardError.write(Data(("could not read image at " + inputPath).utf8))',
|
|
1076
|
+
' exit(65)',
|
|
1077
|
+
'}',
|
|
1078
|
+
'guard let box = alphaBoundingBox(image) else {',
|
|
1079
|
+
' FileHandle.standardError.write(Data("image is fully transparent; nothing to trim".utf8))',
|
|
1080
|
+
' exit(66)',
|
|
1081
|
+
'}',
|
|
1082
|
+
'guard let cropped = image.cropping(to: box) else {',
|
|
1083
|
+
' FileHandle.standardError.write(Data("could not crop image".utf8))',
|
|
1084
|
+
' exit(67)',
|
|
1085
|
+
'}',
|
|
1086
|
+
'let croppedWidth = cropped.width',
|
|
1087
|
+
'let croppedHeight = cropped.height',
|
|
1088
|
+
'guard writePNG(cropped, to: outputPath) else {',
|
|
1089
|
+
' FileHandle.standardError.write(Data(("could not write trimmed PNG to " + outputPath).utf8))',
|
|
1090
|
+
' exit(68)',
|
|
1091
|
+
'}',
|
|
1092
|
+
'',
|
|
1093
|
+
'// Optional aligned output: the trimmed image resized to the reference pixel',
|
|
1094
|
+
'// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
|
|
1095
|
+
'var referenceWidth = 0',
|
|
1096
|
+
'var referenceHeight = 0',
|
|
1097
|
+
'if arguments.count >= 5 {',
|
|
1098
|
+
' let alignedPath = arguments[3]',
|
|
1099
|
+
' let referencePath = arguments[4]',
|
|
1100
|
+
' if let reference = loadCGImage(referencePath) {',
|
|
1101
|
+
' referenceWidth = reference.width',
|
|
1102
|
+
' referenceHeight = reference.height',
|
|
1103
|
+
' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
|
|
1104
|
+
' _ = writePNG(aligned, to: alignedPath)',
|
|
1105
|
+
' }',
|
|
1106
|
+
' }',
|
|
1107
|
+
'}',
|
|
1108
|
+
'',
|
|
1109
|
+
'// Report: rendered (trimmed) px then reference px (0 0 when unavailable).',
|
|
1110
|
+
'print("TRIMDIMS " + String(croppedWidth) + " " + String(croppedHeight) + " " + String(referenceWidth) + " " + String(referenceHeight))'
|
|
1111
|
+
].join('\n') + '\n';
|
|
1112
|
+
|
|
1113
|
+
// Write the helper into the harness dir once (idempotent across screens).
|
|
1114
|
+
function ensureTrimHelper(harnessDir) {
|
|
1115
|
+
var helperPath = path.join(harnessDir, 'Trim.swift');
|
|
1116
|
+
if (!fs.existsSync(helperPath)) {
|
|
1117
|
+
fs.writeFileSync(helperPath, TRIM_HELPER_SWIFT);
|
|
1118
|
+
}
|
|
1119
|
+
return helperPath;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Round to two decimals, keeping the value a plain number.
|
|
1123
|
+
function roundTwo(value) {
|
|
1124
|
+
return Math.round(value * 100) / 100;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Parse the helper's "TRIMDIMS <rW> <rH> <refW> <refH>" line (last one wins).
|
|
1128
|
+
function parseTrimDims(stdout) {
|
|
1129
|
+
var lines = String(stdout || '').split('\n');
|
|
1130
|
+
for (var i = lines.length - 1; i >= 0; i--) {
|
|
1131
|
+
var parts = lines[i].trim().split(/\s+/);
|
|
1132
|
+
if (parts[0] === 'TRIMDIMS' && parts.length >= 5) {
|
|
1133
|
+
var nums = [Number(parts[1]), Number(parts[2]), Number(parts[3]), Number(parts[4])];
|
|
1134
|
+
if (nums.every(function (n) { return Number.isFinite(n); })) {
|
|
1135
|
+
return { rendered: [nums[0], nums[1]], reference: [nums[2], nums[3]] };
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Crop the rendered PNG in place to its content bounding box, write an aligned
|
|
1143
|
+
// copy resized to the reference, and record the dimensions on the entry.
|
|
1144
|
+
// Best-effort: a failure is logged and the untrimmed render is kept.
|
|
1145
|
+
function trimRenderedImage(entry, renderedPath, harnessDir) {
|
|
1146
|
+
var helperPath = ensureTrimHelper(harnessDir);
|
|
1147
|
+
var alignedRelative = 'ui-fidelity/aligned/' + entry.screen + '.png';
|
|
1148
|
+
var alignedPath = path.resolve(artifactsDir, alignedRelative);
|
|
1149
|
+
var referencePath =
|
|
1150
|
+
entry.reference_image_path === null
|
|
1151
|
+
? ''
|
|
1152
|
+
: path.resolve(artifactsDir, entry.reference_image_path);
|
|
1153
|
+
try {
|
|
1154
|
+
fs.mkdirSync(path.dirname(alignedPath), { recursive: true });
|
|
1155
|
+
} catch (mkdirError) {
|
|
1156
|
+
// best effort -- the helper reports a missing output dir itself
|
|
1157
|
+
}
|
|
1158
|
+
var result = runSwift([helperPath, renderedPath, renderedPath, alignedPath, referencePath]);
|
|
1159
|
+
if (result.status !== 0) {
|
|
1160
|
+
logError(
|
|
1161
|
+
'trim failed for ' + renderedPath +
|
|
1162
|
+
' (continuing with the untrimmed render): ' + sanitizeMessage(outputTail(result))
|
|
953
1163
|
);
|
|
954
1164
|
return;
|
|
955
1165
|
}
|
|
956
|
-
|
|
1166
|
+
var dims = parseTrimDims(result.stdout);
|
|
1167
|
+
if (dims !== null) {
|
|
1168
|
+
var scale = CONFIG.scale;
|
|
1169
|
+
entry.dimensions = {
|
|
1170
|
+
rendered_px: dims.rendered,
|
|
1171
|
+
logical_pt: [roundTwo(dims.rendered[0] / scale), roundTwo(dims.rendered[1] / scale)],
|
|
1172
|
+
reference_px: dims.reference[0] > 0 && dims.reference[1] > 0 ? dims.reference : null,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (fs.existsSync(alignedPath)) {
|
|
1176
|
+
entry.aligned_image_path = alignedRelative;
|
|
1177
|
+
}
|
|
957
1178
|
}
|
|
958
1179
|
`;
|
|
959
1180
|
/**
|
|
@@ -1063,6 +1284,21 @@ function buildPackageSourceMain() {
|
|
|
1063
1284
|
main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_ASSET_CATALOG_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
|
|
1064
1285
|
main = replaceOnce(main, ' renderable.forEach(function (entry) {', ' compileAssetCatalogs(packagePath, harnessDir);\n\n' +
|
|
1065
1286
|
' renderable.forEach(function (entry) {');
|
|
1287
|
+
// Render-trimming stage: define the helpers ahead of renderWithHarness, then
|
|
1288
|
+
// crop each rendered PNG to its content bounding box right after it renders.
|
|
1289
|
+
main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_TRIM_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
|
|
1290
|
+
main = replaceOnce(main, " log('rendered ' + entry.screen + ' -> ' + relative);", " log('rendered ' + entry.screen + ' -> ' + relative);\n" +
|
|
1291
|
+
' trimRenderedImage(entry, outputPath, harnessDir);');
|
|
1292
|
+
// Per-screen result entries gain the trimmed-render fields: the aligned
|
|
1293
|
+
// (reference-sized) image and the rendered/logical/reference dimensions.
|
|
1294
|
+
// They stay null until a screen renders and is trimmed.
|
|
1295
|
+
main = replaceOnce(main, ' reference_image_path: null,\n' +
|
|
1296
|
+
' rendered_image_path: null,\n' +
|
|
1297
|
+
' };', ' reference_image_path: null,\n' +
|
|
1298
|
+
' rendered_image_path: null,\n' +
|
|
1299
|
+
' aligned_image_path: null,\n' +
|
|
1300
|
+
' dimensions: null,\n' +
|
|
1301
|
+
' };');
|
|
1066
1302
|
return main;
|
|
1067
1303
|
}
|
|
1068
1304
|
const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
|
|
@@ -68,7 +68,11 @@ const FAKE_SWIFT_LINES = [
|
|
|
68
68
|
' *) shift ;;',
|
|
69
69
|
' esac',
|
|
70
70
|
' done',
|
|
71
|
-
' if [ -n "$showbin" ]; then
|
|
71
|
+
' if [ -n "$showbin" ]; then',
|
|
72
|
+
' bp="$pkg/.build/debug"',
|
|
73
|
+
' [ -n "$FAKE_SWIFT_MODULE_CATALOG" ] && mkdir -p "$bp/$FAKE_SWIFT_MODULE_CATALOG.bundle/Media.xcassets"',
|
|
74
|
+
' echo "$bp"; exit 0',
|
|
75
|
+
' fi',
|
|
72
76
|
' name="${product:-$target}"',
|
|
73
77
|
' if [ "$name" = "RenderProbe" ]; then',
|
|
74
78
|
' if [ -n "$FAKE_SWIFT_PROBE_FAIL" ]; then',
|
|
@@ -107,6 +111,22 @@ const FAKE_SWIFT_LINES = [
|
|
|
107
111
|
" printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out\"",
|
|
108
112
|
' printf \'fake-render:%s\' "$screen" >> "$out"',
|
|
109
113
|
' exit 0 ;;',
|
|
114
|
+
' *.swift)',
|
|
115
|
+
// Script-interpreter mode (swift <Trim.swift> <in> <out> <aligned> <ref>):
|
|
116
|
+
// stands in for the CoreGraphics trim helper. Overwrites <out> with a PNG
|
|
117
|
+
// marked "trimmed:<in basename>" and writes an "aligned:<in basename>" PNG so
|
|
118
|
+
// tests can prove both outputs. Emits a fixed TRIMDIMS line (rendered
|
|
119
|
+
// 320x640, reference 160x320). FAKE_TRIM_FAIL exercises warn-and-continue.
|
|
120
|
+
' if [ -n "$FAKE_TRIM_FAIL" ]; then echo "trim: simulated failure" >&2; exit 1; fi',
|
|
121
|
+
' in_png="$1"; out_png="$2"; aligned_png="$3"',
|
|
122
|
+
" printf '\\x89PNG\\r\\n\\x1a\\n' > \"$out_png\"",
|
|
123
|
+
' printf \'trimmed:%s\' "$(basename "$in_png")" >> "$out_png"',
|
|
124
|
+
' if [ -n "$aligned_png" ]; then',
|
|
125
|
+
" printf '\\x89PNG\\r\\n\\x1a\\n' > \"$aligned_png\"",
|
|
126
|
+
' printf \'aligned:%s\' "$(basename "$in_png")" >> "$aligned_png"',
|
|
127
|
+
' fi',
|
|
128
|
+
' echo "TRIMDIMS 320 640 160 320"',
|
|
129
|
+
' exit 0 ;;',
|
|
110
130
|
' *)',
|
|
111
131
|
' exit 0 ;;',
|
|
112
132
|
'esac',
|
|
@@ -892,6 +912,12 @@ describe('render script runtime (package_source: repo)', () => {
|
|
|
892
912
|
error: null,
|
|
893
913
|
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
894
914
|
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
915
|
+
aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
|
|
916
|
+
dimensions: {
|
|
917
|
+
rendered_px: [320, 640],
|
|
918
|
+
logical_pt: [160, 320],
|
|
919
|
+
reference_px: [160, 320],
|
|
920
|
+
},
|
|
895
921
|
},
|
|
896
922
|
],
|
|
897
923
|
});
|
|
@@ -915,6 +941,29 @@ describe('render script runtime (package_source: repo)', () => {
|
|
|
915
941
|
expect(xcrunCalls).toContain('--compile');
|
|
916
942
|
expect(xcrunCalls).toContain('--platform macosx');
|
|
917
943
|
});
|
|
944
|
+
test('also compiles catalogs in module bundles for Bundle.module references', async () => {
|
|
945
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
946
|
+
writeReference(project, 'home.png');
|
|
947
|
+
// Source catalog drives the main-bundle (Bundle.main) compile. The module
|
|
948
|
+
// bundle — which the fake toolchain materializes to mimic `swift build`
|
|
949
|
+
// copying a .process resource — must ALSO be compiled in place, or an
|
|
950
|
+
// Image("name", bundle: .module) reference finds only the raw catalog.
|
|
951
|
+
const catalog = join(project.packageDir, 'Assets', 'Media.xcassets');
|
|
952
|
+
mkdirSync(catalog, { recursive: true });
|
|
953
|
+
writeFileSync(join(catalog, 'Contents.json'), '{}');
|
|
954
|
+
const script = await buildScript(project, { package_source: 'repo' });
|
|
955
|
+
const xcrunLog = join(project.dir, 'xcrun-invocations.log');
|
|
956
|
+
const run = runScript(project, script, {
|
|
957
|
+
FAKE_XCRUN_LOG: xcrunLog,
|
|
958
|
+
FAKE_SWIFT_MODULE_CATALOG: 'FixtureViews',
|
|
959
|
+
});
|
|
960
|
+
expect(run.status).toBe(0);
|
|
961
|
+
const calls = readFileSync(xcrunLog, 'utf-8').trim().split('\n');
|
|
962
|
+
// The module bundle's own catalog is compiled into the bundle directory.
|
|
963
|
+
const moduleCompile = calls.find((c) => c.includes('.bundle/Media.xcassets'));
|
|
964
|
+
expect(moduleCompile).toBeTruthy();
|
|
965
|
+
expect(moduleCompile).toMatch(/--compile \S+\.bundle(\s|$)/);
|
|
966
|
+
});
|
|
918
967
|
test('does not invoke actool when the package has no asset catalogs', async () => {
|
|
919
968
|
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
920
969
|
writeReference(project, 'home.png');
|
|
@@ -938,6 +987,73 @@ describe('render script runtime (package_source: repo)', () => {
|
|
|
938
987
|
expect(run.stderr).toContain('asset-catalog compile failed');
|
|
939
988
|
});
|
|
940
989
|
});
|
|
990
|
+
describe('render output trimming (package_source variants)', () => {
|
|
991
|
+
// The renderer forces the view into a fixed device-sized frame, so a small
|
|
992
|
+
// component lands in a large canvas with transparent margins. After each
|
|
993
|
+
// screen renders, the trim helper crops the PNG in place to its content
|
|
994
|
+
// bounding box so the rendered artifact IS the component, tight.
|
|
995
|
+
function readArtifact(project, relative) {
|
|
996
|
+
return readFileSync(artifact(project, relative), 'utf-8');
|
|
997
|
+
}
|
|
998
|
+
test('trims the rendered PNG in place via the Swift helper', async () => {
|
|
999
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
1000
|
+
writeReference(project, 'home.png');
|
|
1001
|
+
const script = await buildScript(project, { package_source: 'repo' });
|
|
1002
|
+
const logPath = join(project.dir, 'swift-invocations.log');
|
|
1003
|
+
const run = runScript(project, script, { FAKE_SWIFT_LOG: logPath });
|
|
1004
|
+
expect(run.status).toBe(0);
|
|
1005
|
+
// The helper is invoked in script mode with the rendered PNG as in and out
|
|
1006
|
+
// (in-place overwrite), after the render run.
|
|
1007
|
+
const invocations = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
1008
|
+
const trimCall = invocations.find((c) => c.includes('Trim.swift'));
|
|
1009
|
+
expect(trimCall).toBeTruthy();
|
|
1010
|
+
expect(trimCall).toContain('ui-fidelity/rendered/HomeView.png');
|
|
1011
|
+
// The rendered file now holds the trim helper's output, not the raw render.
|
|
1012
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
|
|
1013
|
+
expect(readArtifact(project, 'ui-fidelity/rendered/HomeView.png')).toContain('trimmed:HomeView.png');
|
|
1014
|
+
});
|
|
1015
|
+
test('writes an aligned image and records rendered/logical/reference dimensions', async () => {
|
|
1016
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
1017
|
+
writeReference(project, 'home.png');
|
|
1018
|
+
const run = runScript(project, await buildScript(project, { package_source: 'repo' }));
|
|
1019
|
+
expect(run.status).toBe(0);
|
|
1020
|
+
// The aligned (reference-sized) image is a separate artifact.
|
|
1021
|
+
expect(isPng(artifact(project, 'ui-fidelity/aligned/HomeView.png'))).toBe(true);
|
|
1022
|
+
expect(readArtifact(project, 'ui-fidelity/aligned/HomeView.png')).toContain('aligned:HomeView.png');
|
|
1023
|
+
// Dimensions are explicit: rendered px from the helper, logical points
|
|
1024
|
+
// derived from the scale (320/2, 640/2), reference px from the helper.
|
|
1025
|
+
const screen = readResult(project).screens[0];
|
|
1026
|
+
expect(screen.aligned_image_path).toBe('ui-fidelity/aligned/HomeView.png');
|
|
1027
|
+
expect(screen.dimensions).toEqual({
|
|
1028
|
+
rendered_px: [320, 640],
|
|
1029
|
+
logical_pt: [160, 320],
|
|
1030
|
+
reference_px: [160, 320],
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
test('a failing trim warns but still renders (warn-and-continue)', async () => {
|
|
1034
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
1035
|
+
writeReference(project, 'home.png');
|
|
1036
|
+
const script = await buildScript(project, { package_source: 'repo' });
|
|
1037
|
+
const run = runScript(project, script, { FAKE_TRIM_FAIL: '1' });
|
|
1038
|
+
// A trim failure never fails the step: the untrimmed render survives, with
|
|
1039
|
+
// no aligned image and no dimensions.
|
|
1040
|
+
expect(run.status).toBe(0);
|
|
1041
|
+
expect(isPng(artifact(project, 'ui-fidelity/rendered/HomeView.png'))).toBe(true);
|
|
1042
|
+
const screen = readResult(project).screens[0];
|
|
1043
|
+
expect(screen.status).toBe('rendered');
|
|
1044
|
+
expect(screen.aligned_image_path).toBeNull();
|
|
1045
|
+
expect(screen.dimensions).toBeNull();
|
|
1046
|
+
expect(run.stderr).toContain('trim failed');
|
|
1047
|
+
});
|
|
1048
|
+
test('trims renders shipped via package_source: inputs too', async () => {
|
|
1049
|
+
const project = makeProject({ screens: { HomeView: 'home.png' } });
|
|
1050
|
+
writeReference(project, 'home.png');
|
|
1051
|
+
stageArchive(project, shippedPackageEntries('pkg'));
|
|
1052
|
+
const run = runScript(project, await buildInputsScript({ target: 'FixtureViews' }));
|
|
1053
|
+
expect(run.status).toBe(0);
|
|
1054
|
+
expect(readArtifact(project, 'ui-fidelity/rendered/HomeView.png')).toContain('trimmed:HomeView.png');
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
941
1057
|
describe('render script runtime (package_source: inputs)', () => {
|
|
942
1058
|
test('renders every screen from a shipped archive with a single package root', async () => {
|
|
943
1059
|
const project = makeProject({
|
|
@@ -966,6 +1082,12 @@ describe('render script runtime (package_source: inputs)', () => {
|
|
|
966
1082
|
error: null,
|
|
967
1083
|
reference_image_path: 'ui-fidelity/references/HomeView.png',
|
|
968
1084
|
rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
|
|
1085
|
+
aligned_image_path: 'ui-fidelity/aligned/HomeView.png',
|
|
1086
|
+
dimensions: {
|
|
1087
|
+
rendered_px: [320, 640],
|
|
1088
|
+
logical_pt: [160, 320],
|
|
1089
|
+
reference_px: [160, 320],
|
|
1090
|
+
},
|
|
969
1091
|
},
|
|
970
1092
|
{
|
|
971
1093
|
screen: 'SettingsView',
|
|
@@ -973,6 +1095,12 @@ describe('render script runtime (package_source: inputs)', () => {
|
|
|
973
1095
|
error: null,
|
|
974
1096
|
reference_image_path: 'ui-fidelity/references/SettingsView.png',
|
|
975
1097
|
rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
|
|
1098
|
+
aligned_image_path: 'ui-fidelity/aligned/SettingsView.png',
|
|
1099
|
+
dimensions: {
|
|
1100
|
+
rendered_px: [320, 640],
|
|
1101
|
+
logical_pt: [160, 320],
|
|
1102
|
+
reference_px: [160, 320],
|
|
1103
|
+
},
|
|
976
1104
|
},
|
|
977
1105
|
],
|
|
978
1106
|
});
|