@invarn/cibuild 2.0.1 → 2.0.3
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/cli.cjs +350 -3
- package/dist/src/yaml/steps/index.d.ts.map +1 -1
- package/dist/src/yaml/steps/index.js +14 -5
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +71 -8
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +560 -7
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +611 -7
- package/package.json +1 -1
|
@@ -28,8 +28,25 @@
|
|
|
28
28
|
*
|
|
29
29
|
* protocol-result.json contains relative paths only: image paths are relative
|
|
30
30
|
* to the artifacts dir, and error messages are sanitized so absolute runner
|
|
31
|
-
* paths (resolved package_path, harness temp dir,
|
|
32
|
-
* rewritten to relative paths or placeholders
|
|
31
|
+
* paths (resolved package_path, harness temp dir, extracted shipped package,
|
|
32
|
+
* compiler diagnostics) are rewritten to relative paths or placeholders
|
|
33
|
+
* before they are stored.
|
|
34
|
+
*
|
|
35
|
+
* The package_source input picks where the SwiftPM package comes from:
|
|
36
|
+
* - "repo" (default): the package lives in the checkout at package_path.
|
|
37
|
+
* Omitting the input generates a byte-identical script to the original
|
|
38
|
+
* step (locked by a snapshot test), and the result document keeps its
|
|
39
|
+
* original { renderer, screens } shape.
|
|
40
|
+
* - "inputs": the caller ships the package as .ci/inputs/package.tar.gz.
|
|
41
|
+
* The archive is extracted into a scratch directory and validated
|
|
42
|
+
* (single package root, Package.swift at its top, bounded extracted
|
|
43
|
+
* size); rendering then proceeds exactly as in repo mode. Validation
|
|
44
|
+
* failures are per-build structured errors (PACKAGE_ARCHIVE_MISSING,
|
|
45
|
+
* PACKAGE_ARCHIVE_INVALID, PACKAGE_MANIFEST_MISSING,
|
|
46
|
+
* PACKAGE_ARCHIVE_TOO_LARGE, PACKAGE_TARGET_UNRESOLVED) recorded as a
|
|
47
|
+
* top-level error object in the result document, distinct from
|
|
48
|
+
* per-screen render errors. With an explicit package_source the result
|
|
49
|
+
* document is { renderer, package_source, error, screens }.
|
|
33
50
|
*/
|
|
34
51
|
import { BaseStepExecutor } from './base.js';
|
|
35
52
|
/**
|
|
@@ -41,6 +58,21 @@ import { BaseStepExecutor } from './base.js';
|
|
|
41
58
|
export const DEFAULT_RENDER_SIZE = '393x852';
|
|
42
59
|
/** Default display scale (@2x), matching how references are typically exported. */
|
|
43
60
|
export const DEFAULT_SCALE = 2;
|
|
61
|
+
/** Valid values for the package_source input. */
|
|
62
|
+
export const PACKAGE_SOURCES = ['repo', 'inputs'];
|
|
63
|
+
/** Default package source: the package lives in the repo checkout. */
|
|
64
|
+
export const DEFAULT_PACKAGE_SOURCE = 'repo';
|
|
65
|
+
/** Basename of the shipped package archive inside the run-inputs directory. */
|
|
66
|
+
export const PACKAGE_ARCHIVE_BASENAME = 'package.tar.gz';
|
|
67
|
+
/**
|
|
68
|
+
* Default cap on the extracted size of a shipped package archive (32 MiB),
|
|
69
|
+
* overridable at runtime via UI_FIDELITY_MAX_PACKAGE_BYTES. The archive
|
|
70
|
+
* arrives through a size-limited dispatch channel (256 KiB), and gzip tops
|
|
71
|
+
* out around a 1000:1 ratio, so this bound is a decompression-bomb guard
|
|
72
|
+
* with enormous headroom for legitimate source slices, which are typically
|
|
73
|
+
* well under 1 MiB extracted.
|
|
74
|
+
*/
|
|
75
|
+
export const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
|
|
44
76
|
/**
|
|
45
77
|
* Parses a "<width>x<height>" render size string (device points).
|
|
46
78
|
* @throws Error when the value is malformed or non-positive
|
|
@@ -555,6 +587,485 @@ try {
|
|
|
555
587
|
}
|
|
556
588
|
process.exit(exitCode);
|
|
557
589
|
`;
|
|
590
|
+
/**
|
|
591
|
+
* Runtime support for package_source "inputs": locating, extracting, and
|
|
592
|
+
* validating the shipped archive, plus target discovery from its manifest.
|
|
593
|
+
* Inserted into the runtime script only when package_source is explicitly
|
|
594
|
+
* configured. Plain dependency-free CJS; must not contain backticks, "${",
|
|
595
|
+
* or bare $UPPERCASE tokens (the pre-execution validator would parse those
|
|
596
|
+
* as variable references).
|
|
597
|
+
*/
|
|
598
|
+
const RENDER_SCRIPT_PACKAGE_STAGE = String.raw `
|
|
599
|
+
// ---- shipped-package stage (package_source: "inputs") ----
|
|
600
|
+
//
|
|
601
|
+
// The caller ships a SwiftPM package as .ci/inputs/package.tar.gz. Before
|
|
602
|
+
// any rendering, the archive is located, extracted into a scratch
|
|
603
|
+
// directory, and validated. Failures here are PER-BUILD structured errors,
|
|
604
|
+
// recorded as the result document's top-level error object -- distinct from
|
|
605
|
+
// per-screen render errors, which keep their v1 semantics. The extracted
|
|
606
|
+
// size is bounded as a decompression-bomb guard.
|
|
607
|
+
|
|
608
|
+
var PACKAGE_ARCHIVE_NAME = 'package.tar.gz';
|
|
609
|
+
var DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
|
|
610
|
+
var maxExtractedBytes =
|
|
611
|
+
Number(process.env.UI_FIDELITY_MAX_PACKAGE_BYTES || '') || DEFAULT_MAX_EXTRACTED_BYTES;
|
|
612
|
+
|
|
613
|
+
var buildError = null;
|
|
614
|
+
var activeTarget = CONFIG.target;
|
|
615
|
+
var shippedExtractDir = null;
|
|
616
|
+
|
|
617
|
+
function setBuildError(code, message) {
|
|
618
|
+
buildError = { code: code, message: sanitizeMessage(message) };
|
|
619
|
+
logError(code + ': ' + buildError.message);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function runTar(args) {
|
|
623
|
+
var result = cp.spawnSync('tar', args, {
|
|
624
|
+
encoding: 'utf-8',
|
|
625
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
626
|
+
});
|
|
627
|
+
if (result.error) {
|
|
628
|
+
result.status = result.status == null ? 127 : result.status;
|
|
629
|
+
result.stderr =
|
|
630
|
+
(result.stderr || '') + String(result.error.message || result.error);
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function isUnsafeArchiveMember(name) {
|
|
636
|
+
if (name.charAt(0) === '/') return true;
|
|
637
|
+
var segments = name.split('/');
|
|
638
|
+
for (var i = 0; i < segments.length; i++) {
|
|
639
|
+
if (segments[i] === '..') return true;
|
|
640
|
+
}
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function extractedSize(dir) {
|
|
645
|
+
var total = 0;
|
|
646
|
+
var stack = [dir];
|
|
647
|
+
while (stack.length > 0) {
|
|
648
|
+
var current = stack.pop();
|
|
649
|
+
var names = fs.readdirSync(current);
|
|
650
|
+
for (var i = 0; i < names.length; i++) {
|
|
651
|
+
var entryPath = path.join(current, names[i]);
|
|
652
|
+
var stat = fs.lstatSync(entryPath);
|
|
653
|
+
if (stat.isDirectory()) stack.push(entryPath);
|
|
654
|
+
else total += stat.size;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return total;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Entries that macOS archiving tools add but that say nothing about the
|
|
661
|
+
// package layout are ignored when locating the package root.
|
|
662
|
+
function isJunkEntry(name) {
|
|
663
|
+
return name === '.DS_Store' || name === '__MACOSX' || name.indexOf('._') === 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function hasManifest(dir) {
|
|
667
|
+
try {
|
|
668
|
+
return fs.statSync(path.join(dir, 'Package.swift')).isFile();
|
|
669
|
+
} catch (statError) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Package-root rule: Package.swift at the extraction root wins; otherwise
|
|
675
|
+
// exactly one top-level directory with Package.swift at its top is the
|
|
676
|
+
// root; anything else is a structured per-build error.
|
|
677
|
+
function findPackageRoot(extractDir) {
|
|
678
|
+
if (hasManifest(extractDir)) return extractDir;
|
|
679
|
+
var entries = fs.readdirSync(extractDir).filter(function (name) {
|
|
680
|
+
return !isJunkEntry(name);
|
|
681
|
+
});
|
|
682
|
+
if (entries.length === 0) {
|
|
683
|
+
setBuildError('PACKAGE_ARCHIVE_INVALID', PACKAGE_ARCHIVE_NAME + ' is empty');
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
if (entries.length > 1) {
|
|
687
|
+
setBuildError(
|
|
688
|
+
'PACKAGE_ARCHIVE_INVALID',
|
|
689
|
+
PACKAGE_ARCHIVE_NAME + ' must contain a single package root; found ' +
|
|
690
|
+
entries.length + ' top-level entries (' + entries.sort().join(', ') +
|
|
691
|
+
') and no Package.swift at the archive root'
|
|
692
|
+
);
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
var rootPath = path.join(extractDir, entries[0]);
|
|
696
|
+
if (!fs.statSync(rootPath).isDirectory()) {
|
|
697
|
+
setBuildError(
|
|
698
|
+
'PACKAGE_ARCHIVE_INVALID',
|
|
699
|
+
PACKAGE_ARCHIVE_NAME + ' must contain a package directory; its only ' +
|
|
700
|
+
'top-level entry (' + entries[0] + ') is not a directory'
|
|
701
|
+
);
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
if (!hasManifest(rootPath)) {
|
|
705
|
+
setBuildError(
|
|
706
|
+
'PACKAGE_MANIFEST_MISSING',
|
|
707
|
+
'no Package.swift at the top of the package root (' + entries[0] + ') in ' +
|
|
708
|
+
PACKAGE_ARCHIVE_NAME
|
|
709
|
+
);
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
return rootPath;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// When no target input is configured, the target is discovered from the
|
|
716
|
+
// shipped package's manifest: the package must declare exactly one library
|
|
717
|
+
// product. The harness depends on the package via .product(name:package:),
|
|
718
|
+
// so library products are the unit of discovery.
|
|
719
|
+
function resolveTargetFromManifest(packageRoot) {
|
|
720
|
+
var dump = runSwift(['package', '--package-path', packageRoot, 'dump-package']);
|
|
721
|
+
if (dump.status !== 0) {
|
|
722
|
+
setBuildError(
|
|
723
|
+
'PACKAGE_TARGET_UNRESOLVED',
|
|
724
|
+
'could not read the shipped package manifest (swift package dump-package failed):\n' +
|
|
725
|
+
outputTail(dump)
|
|
726
|
+
);
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
var manifest = null;
|
|
730
|
+
try {
|
|
731
|
+
manifest = JSON.parse(dump.stdout);
|
|
732
|
+
} catch (parseError) {
|
|
733
|
+
setBuildError(
|
|
734
|
+
'PACKAGE_TARGET_UNRESOLVED',
|
|
735
|
+
'swift package dump-package produced unparseable output: ' +
|
|
736
|
+
(parseError && parseError.message ? parseError.message : String(parseError))
|
|
737
|
+
);
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
var libraries = ((manifest && manifest.products) || [])
|
|
741
|
+
.filter(function (product) {
|
|
742
|
+
return product && product.type && typeof product.type === 'object' &&
|
|
743
|
+
'library' in product.type;
|
|
744
|
+
})
|
|
745
|
+
.map(function (product) { return String(product.name); });
|
|
746
|
+
if (libraries.length === 0) {
|
|
747
|
+
setBuildError(
|
|
748
|
+
'PACKAGE_TARGET_UNRESOLVED',
|
|
749
|
+
'the shipped package declares no library products; declare exactly one ' +
|
|
750
|
+
'library product, or set the target input'
|
|
751
|
+
);
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
if (libraries.length > 1) {
|
|
755
|
+
setBuildError(
|
|
756
|
+
'PACKAGE_TARGET_UNRESOLVED',
|
|
757
|
+
'the shipped package declares ' + libraries.length + ' library products (' +
|
|
758
|
+
libraries.sort().join(', ') + '); set the target input to pick one'
|
|
759
|
+
);
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
if (!SWIFT_IDENTIFIER.test(libraries[0])) {
|
|
763
|
+
setBuildError(
|
|
764
|
+
'PACKAGE_TARGET_UNRESOLVED',
|
|
765
|
+
'discovered library product ' + JSON.stringify(libraries[0]) +
|
|
766
|
+
' is not a valid Swift module identifier'
|
|
767
|
+
);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
return libraries[0];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Locates, extracts, and validates the shipped archive. Returns the package
|
|
774
|
+
// root to build from, or null after recording a structured build error.
|
|
775
|
+
function prepareShippedPackage() {
|
|
776
|
+
var archivePath = path.join(inputsDir, PACKAGE_ARCHIVE_NAME);
|
|
777
|
+
var archiveStat = null;
|
|
778
|
+
try {
|
|
779
|
+
archiveStat = fs.statSync(archivePath);
|
|
780
|
+
} catch (statError) {
|
|
781
|
+
archiveStat = null;
|
|
782
|
+
}
|
|
783
|
+
if (!archiveStat || !archiveStat.isFile()) {
|
|
784
|
+
setBuildError(
|
|
785
|
+
'PACKAGE_ARCHIVE_MISSING',
|
|
786
|
+
'no ' + PACKAGE_ARCHIVE_NAME + ' in ' + inputsDir + '; ship the SwiftPM ' +
|
|
787
|
+
'package as a run input named ' + PACKAGE_ARCHIVE_NAME
|
|
788
|
+
);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// bsdtar and GNU tar both refuse absolute and dot-dot member paths by
|
|
793
|
+
// default; the listing is checked explicitly anyway so the guard does not
|
|
794
|
+
// depend on the host tar implementation. A failed listing also catches
|
|
795
|
+
// unreadable or corrupt archives before anything touches the disk.
|
|
796
|
+
var listing = runTar(['-tzf', archivePath]);
|
|
797
|
+
if (listing.status !== 0) {
|
|
798
|
+
setBuildError(
|
|
799
|
+
'PACKAGE_ARCHIVE_INVALID',
|
|
800
|
+
PACKAGE_ARCHIVE_NAME + ' is not a readable gzipped tar archive:\n' +
|
|
801
|
+
outputTail(listing)
|
|
802
|
+
);
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
var members = (listing.stdout || '').split('\n').filter(function (name) {
|
|
806
|
+
return name !== '';
|
|
807
|
+
});
|
|
808
|
+
var unsafe = members.filter(isUnsafeArchiveMember);
|
|
809
|
+
if (unsafe.length > 0) {
|
|
810
|
+
setBuildError(
|
|
811
|
+
'PACKAGE_ARCHIVE_INVALID',
|
|
812
|
+
PACKAGE_ARCHIVE_NAME + ' contains unsafe member paths (absolute or dot-dot): ' +
|
|
813
|
+
unsafe.slice(0, 5).join(', ')
|
|
814
|
+
);
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
shippedExtractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-package-'));
|
|
819
|
+
registerPathReplacement(shippedExtractDir, '<package>');
|
|
820
|
+
log('extracting ' + PACKAGE_ARCHIVE_NAME + ' (' + archiveStat.size + ' bytes)');
|
|
821
|
+
var extraction = runTar(['-xzf', archivePath, '-C', shippedExtractDir]);
|
|
822
|
+
if (extraction.status !== 0) {
|
|
823
|
+
setBuildError(
|
|
824
|
+
'PACKAGE_ARCHIVE_INVALID',
|
|
825
|
+
PACKAGE_ARCHIVE_NAME + ' could not be extracted:\n' + outputTail(extraction)
|
|
826
|
+
);
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
var totalBytes = extractedSize(shippedExtractDir);
|
|
831
|
+
if (totalBytes > maxExtractedBytes) {
|
|
832
|
+
setBuildError(
|
|
833
|
+
'PACKAGE_ARCHIVE_TOO_LARGE',
|
|
834
|
+
PACKAGE_ARCHIVE_NAME + ' extracts to ' + totalBytes + ' bytes, over the ' +
|
|
835
|
+
maxExtractedBytes + '-byte limit; ship a leaner source slice'
|
|
836
|
+
);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
var packageRoot = findPackageRoot(shippedExtractDir);
|
|
841
|
+
if (packageRoot === null) return null;
|
|
842
|
+
var rootLabel = path.relative(shippedExtractDir, packageRoot);
|
|
843
|
+
log('package root: ' + (rootLabel === '' ? '.' : rootLabel));
|
|
844
|
+
|
|
845
|
+
if (activeTarget === null) {
|
|
846
|
+
var discovered = resolveTargetFromManifest(packageRoot);
|
|
847
|
+
if (discovered === null) return null;
|
|
848
|
+
log('discovered library target: ' + discovered);
|
|
849
|
+
activeTarget = discovered;
|
|
850
|
+
}
|
|
851
|
+
return packageRoot;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function cleanupShippedPackage() {
|
|
855
|
+
if (shippedExtractDir === null) return;
|
|
856
|
+
if (process.env.UI_FIDELITY_KEEP_HARNESS) {
|
|
857
|
+
log('keeping extracted package (UI_FIDELITY_KEEP_HARNESS set)');
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
fs.rmSync(shippedExtractDir, { recursive: true, force: true });
|
|
862
|
+
} catch (cleanupError) {
|
|
863
|
+
// best effort
|
|
864
|
+
}
|
|
865
|
+
shippedExtractDir = null;
|
|
866
|
+
}
|
|
867
|
+
`;
|
|
868
|
+
/**
|
|
869
|
+
* Runtime support for asset catalogs (package_source variants only).
|
|
870
|
+
*
|
|
871
|
+
* `swift build` copies a target's .xcassets into its resource bundle but never
|
|
872
|
+
* runs actool, so SwiftUI's Image("name") finds no compiled catalog and the
|
|
873
|
+
* image does not resolve. After the harness builds, compile every .xcassets in
|
|
874
|
+
* the package with actool into the harness bin directory (= the render
|
|
875
|
+
* executable's Bundle.main), so unmodified Image("name") references resolve.
|
|
876
|
+
* One actool invocation over all catalogs (it merges them). Best-effort:
|
|
877
|
+
* a compile failure is logged and rendering continues -- the affected images
|
|
878
|
+
* simply will not appear, which the human comparing the screens will see.
|
|
879
|
+
*/
|
|
880
|
+
const RENDER_SCRIPT_ASSET_CATALOG_STAGE = String.raw `
|
|
881
|
+
// ---- asset-catalog stage (package_source variants) ----
|
|
882
|
+
|
|
883
|
+
var ASSET_CATALOG_MIN_MACOS = '13.0';
|
|
884
|
+
|
|
885
|
+
// Recursively collect *.xcassets directories under root, skipping .build and
|
|
886
|
+
// the catalogs' own contents (a catalog is a leaf for this scan).
|
|
887
|
+
function findAssetCatalogs(root) {
|
|
888
|
+
var found = [];
|
|
889
|
+
var stack = [root];
|
|
890
|
+
while (stack.length > 0) {
|
|
891
|
+
var dir = stack.pop();
|
|
892
|
+
var entries;
|
|
893
|
+
try {
|
|
894
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
895
|
+
} catch (readError) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
for (var i = 0; i < entries.length; i++) {
|
|
899
|
+
var entry = entries[i];
|
|
900
|
+
if (!entry.isDirectory()) continue;
|
|
901
|
+
if (entry.name === '.build') continue;
|
|
902
|
+
var full = path.join(dir, entry.name);
|
|
903
|
+
if (entry.name.slice(-9) === '.xcassets') {
|
|
904
|
+
found.push(full);
|
|
905
|
+
} else {
|
|
906
|
+
stack.push(full);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return found.sort();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function compileAssetCatalogs(packageDir, harnessDir) {
|
|
914
|
+
var catalogs = findAssetCatalogs(packageDir);
|
|
915
|
+
if (catalogs.length === 0) return;
|
|
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
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
fs.mkdirSync(binPath, { recursive: true });
|
|
929
|
+
} catch (mkdirError) {
|
|
930
|
+
// best effort -- actool reports a missing output dir itself
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
var args = ['actool'].concat(catalogs);
|
|
934
|
+
args.push(
|
|
935
|
+
'--compile', binPath,
|
|
936
|
+
'--platform', 'macosx',
|
|
937
|
+
'--minimum-deployment-target', ASSET_CATALOG_MIN_MACOS,
|
|
938
|
+
'--output-format', 'human-readable-text'
|
|
939
|
+
);
|
|
940
|
+
log('xcrun ' + args.join(' '));
|
|
941
|
+
var result = cp.spawnSync('xcrun', args, {
|
|
942
|
+
encoding: 'utf-8',
|
|
943
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
944
|
+
});
|
|
945
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
946
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
947
|
+
if (result.error || result.status !== 0) {
|
|
948
|
+
var detail = (result.stderr || '') +
|
|
949
|
+
(result.error ? String(result.error.message || result.error) : '');
|
|
950
|
+
logError(
|
|
951
|
+
'asset-catalog compile failed (continuing; catalog images may not ' +
|
|
952
|
+
'resolve): ' + sanitizeMessage(detail.trim())
|
|
953
|
+
);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
log('compiled ' + catalogs.length + ' asset catalog(s) for macOS');
|
|
957
|
+
}
|
|
958
|
+
`;
|
|
959
|
+
/**
|
|
960
|
+
* Replaces exactly one occurrence of `anchor` in `source`. Throws when the
|
|
961
|
+
* anchor is missing or ambiguous, so any drift between the v1 runtime text
|
|
962
|
+
* and the package-source variant fails loudly at module load.
|
|
963
|
+
*/
|
|
964
|
+
function replaceOnce(source, anchor, replacement) {
|
|
965
|
+
const first = source.indexOf(anchor);
|
|
966
|
+
if (first === -1 || source.indexOf(anchor, first + anchor.length) !== -1) {
|
|
967
|
+
throw new Error('ui-fidelity-render: expected exactly one occurrence of anchor: ' + anchor);
|
|
968
|
+
}
|
|
969
|
+
return source.slice(0, first) + replacement + source.slice(first + anchor.length);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Builds the runtime main for explicitly-configured package_source ("repo"
|
|
973
|
+
* or "inputs") by patching the v1 runtime text. RENDER_SCRIPT_MAIN itself is
|
|
974
|
+
* never modified: omitting package_source keeps the generated script
|
|
975
|
+
* byte-identical to v1 (locked by a snapshot test), and the patch points
|
|
976
|
+
* below are the complete, reviewable diff between the two modes.
|
|
977
|
+
*/
|
|
978
|
+
function buildPackageSourceMain() {
|
|
979
|
+
let main = RENDER_SCRIPT_MAIN;
|
|
980
|
+
// The result document gains the configured package source and a top-level
|
|
981
|
+
// per-build error slot, distinct from per-screen errors.
|
|
982
|
+
main = replaceOnce(main, 'var doc = { renderer: RENDERER, screens: currentEntries };', 'var doc = {\n' +
|
|
983
|
+
' renderer: RENDERER,\n' +
|
|
984
|
+
' package_source: CONFIG.packageSource,\n' +
|
|
985
|
+
' error: buildError,\n' +
|
|
986
|
+
' screens: currentEntries,\n' +
|
|
987
|
+
' };');
|
|
988
|
+
// packagePath is null with package_source "inputs": the package to build
|
|
989
|
+
// from is only known after extraction.
|
|
990
|
+
main = replaceOnce(main, "var resolvedPackagePath = path.resolve(CONFIG.packagePath);\n" +
|
|
991
|
+
'registerPathReplacement(\n' +
|
|
992
|
+
' resolvedPackagePath,\n' +
|
|
993
|
+
" path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
|
|
994
|
+
');', 'var resolvedPackagePath =\n' +
|
|
995
|
+
' CONFIG.packagePath === null ? null : path.resolve(CONFIG.packagePath);\n' +
|
|
996
|
+
'if (resolvedPackagePath !== null) {\n' +
|
|
997
|
+
' registerPathReplacement(\n' +
|
|
998
|
+
' resolvedPackagePath,\n' +
|
|
999
|
+
" path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
|
|
1000
|
+
' );\n' +
|
|
1001
|
+
'}');
|
|
1002
|
+
// The harness target may be discovered at runtime from the shipped
|
|
1003
|
+
// manifest, so renderWithHarness reads the resolved activeTarget.
|
|
1004
|
+
main = replaceOnce(main, 'target: CONFIG.target,', 'target: activeTarget,');
|
|
1005
|
+
main = replaceOnce(main, "' a public View with a parameterless init in ' + CONFIG.target + '?):\\n' +", "' a public View with a parameterless init in ' + activeTarget + '?):\\n' +");
|
|
1006
|
+
// The shipped-package stage runs before any harness build. It is reached
|
|
1007
|
+
// even when no screen is renderable, so packaging mistakes are always
|
|
1008
|
+
// reported; its scratch directory is cleaned up afterwards.
|
|
1009
|
+
main = replaceOnce(main, ' // 3. Render the remaining screens through the synthesized harness.\n' +
|
|
1010
|
+
' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
|
|
1011
|
+
' if (renderable.length > 0) {\n' +
|
|
1012
|
+
' if (!fs.existsSync(resolvedPackagePath)) {\n' +
|
|
1013
|
+
' currentEntries.forEach(function (entry) {\n' +
|
|
1014
|
+
" setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
|
|
1015
|
+
' });\n' +
|
|
1016
|
+
' } else {\n' +
|
|
1017
|
+
' renderWithHarness(renderable, resolvedPackagePath);\n' +
|
|
1018
|
+
' }\n' +
|
|
1019
|
+
' } else if (currentEntries.length > 0) {\n' +
|
|
1020
|
+
" log('no renderable screens, skipping harness build');\n" +
|
|
1021
|
+
' }', ' // 3. Resolve the package to build from, then render the remaining\n' +
|
|
1022
|
+
' // screens through the synthesized harness.\n' +
|
|
1023
|
+
' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
|
|
1024
|
+
" if (CONFIG.packageSource === 'inputs') {\n" +
|
|
1025
|
+
' try {\n' +
|
|
1026
|
+
' var shippedRoot = prepareShippedPackage();\n' +
|
|
1027
|
+
' if (shippedRoot !== null) {\n' +
|
|
1028
|
+
' if (renderable.length > 0) {\n' +
|
|
1029
|
+
' renderWithHarness(renderable, shippedRoot);\n' +
|
|
1030
|
+
' } else if (currentEntries.length > 0) {\n' +
|
|
1031
|
+
" log('no renderable screens, skipping harness build');\n" +
|
|
1032
|
+
' }\n' +
|
|
1033
|
+
' }\n' +
|
|
1034
|
+
' } finally {\n' +
|
|
1035
|
+
' cleanupShippedPackage();\n' +
|
|
1036
|
+
' }\n' +
|
|
1037
|
+
' } else if (renderable.length > 0) {\n' +
|
|
1038
|
+
' if (!fs.existsSync(resolvedPackagePath)) {\n' +
|
|
1039
|
+
' currentEntries.forEach(function (entry) {\n' +
|
|
1040
|
+
" setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
|
|
1041
|
+
' });\n' +
|
|
1042
|
+
' } else {\n' +
|
|
1043
|
+
' renderWithHarness(renderable, resolvedPackagePath);\n' +
|
|
1044
|
+
' }\n' +
|
|
1045
|
+
' } else if (currentEntries.length > 0) {\n' +
|
|
1046
|
+
" log('no renderable screens, skipping harness build');\n" +
|
|
1047
|
+
' }');
|
|
1048
|
+
// A per-build package error fails the step even when no screen reached
|
|
1049
|
+
// the harness (including the zero-screens case).
|
|
1050
|
+
main = replaceOnce(main, ' // 4. Exit code: zero only when every screen rendered.\n' +
|
|
1051
|
+
' writeResult();\n', ' // 4. Exit code: zero only when the shipped package (if any) was valid\n' +
|
|
1052
|
+
' // and every screen rendered.\n' +
|
|
1053
|
+
' writeResult();\n' +
|
|
1054
|
+
' if (buildError !== null) {\n' +
|
|
1055
|
+
" logError('package validation failed: ' + buildError.code);\n" +
|
|
1056
|
+
' return 1;\n' +
|
|
1057
|
+
' }\n');
|
|
1058
|
+
// The shipped-package stage is defined ahead of main().
|
|
1059
|
+
main = replaceOnce(main, 'function main() {', RENDER_SCRIPT_PACKAGE_STAGE + '\nfunction main() {');
|
|
1060
|
+
// Asset-catalog stage: define the helpers ahead of renderWithHarness, then
|
|
1061
|
+
// compile any catalogs once the package has proven it builds (after the
|
|
1062
|
+
// probe) and before the per-screen renders that depend on the images.
|
|
1063
|
+
main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_ASSET_CATALOG_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
|
|
1064
|
+
main = replaceOnce(main, ' renderable.forEach(function (entry) {', ' compileAssetCatalogs(packagePath, harnessDir);\n\n' +
|
|
1065
|
+
' renderable.forEach(function (entry) {');
|
|
1066
|
+
return main;
|
|
1067
|
+
}
|
|
1068
|
+
const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
|
|
558
1069
|
/**
|
|
559
1070
|
* Evaluates the embedded generator source and returns the real functions, so
|
|
560
1071
|
* unit tests exercise exactly the code that ships inside the runtime script.
|
|
@@ -570,6 +1081,10 @@ export function getRenderScriptInternals() {
|
|
|
570
1081
|
/**
|
|
571
1082
|
* Generates the self-contained runtime node script for the step.
|
|
572
1083
|
* Pure function of its config — exported so tests can execute the script.
|
|
1084
|
+
*
|
|
1085
|
+
* When config.packageSource is absent the output is byte-identical to the
|
|
1086
|
+
* pre-package_source script (JSON.stringify drops undefined keys, and the
|
|
1087
|
+
* unpatched v1 runtime is used).
|
|
573
1088
|
*/
|
|
574
1089
|
export function generateRenderScript(config) {
|
|
575
1090
|
return [
|
|
@@ -577,7 +1092,9 @@ export function generateRenderScript(config) {
|
|
|
577
1092
|
'// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
|
|
578
1093
|
'var CONFIG = ' + JSON.stringify(config) + ';',
|
|
579
1094
|
RENDER_SCRIPT_GENERATORS,
|
|
580
|
-
|
|
1095
|
+
config.packageSource === undefined
|
|
1096
|
+
? RENDER_SCRIPT_MAIN
|
|
1097
|
+
: RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE,
|
|
581
1098
|
].join('\n');
|
|
582
1099
|
}
|
|
583
1100
|
/**
|
|
@@ -587,6 +1104,14 @@ export function generateRenderScript(config) {
|
|
|
587
1104
|
*/
|
|
588
1105
|
export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
|
|
589
1106
|
getValidationRequirements(inputs, _env, _config) {
|
|
1107
|
+
if (inputs && inputs.package_source === 'inputs') {
|
|
1108
|
+
// The package ships as .ci/inputs/package.tar.gz at run time, so
|
|
1109
|
+
// neither package_path nor target is required up front.
|
|
1110
|
+
return [
|
|
1111
|
+
this.requireCommand('swift', 'Swift toolchain used to build and run the render harness', 'Install Xcode (or the Swift toolchain) on the runner'),
|
|
1112
|
+
this.requireCommand('tar', 'Archive tool used to extract the shipped package', 'Install tar on the runner'),
|
|
1113
|
+
];
|
|
1114
|
+
}
|
|
590
1115
|
return [
|
|
591
1116
|
this.requireInput('package_path', inputs, 'Path to the SwiftPM package containing the screens'),
|
|
592
1117
|
this.requireInput('target', inputs, 'SPM library product to import in the render harness'),
|
|
@@ -595,15 +1120,43 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
|
|
|
595
1120
|
}
|
|
596
1121
|
async execute(inputs, _env, _config) {
|
|
597
1122
|
const stepName = 'ui-fidelity-render';
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1123
|
+
// Omitted package_source means v1 repo behavior with a byte-identical
|
|
1124
|
+
// generated script; an explicit value (repo or inputs) is recorded in
|
|
1125
|
+
// the script config and the result document.
|
|
1126
|
+
const rawPackageSource = this.getInput(inputs, 'package_source', undefined);
|
|
1127
|
+
let packageSource;
|
|
1128
|
+
if (rawPackageSource !== undefined) {
|
|
1129
|
+
const value = String(rawPackageSource);
|
|
1130
|
+
if (!PACKAGE_SOURCES.includes(value)) {
|
|
1131
|
+
throw new Error(`Invalid package_source '${value}' for step '${stepName}': ` +
|
|
1132
|
+
`expected one of: ${PACKAGE_SOURCES.join(', ')}`);
|
|
1133
|
+
}
|
|
1134
|
+
packageSource = value;
|
|
1135
|
+
}
|
|
1136
|
+
let packagePath = null;
|
|
1137
|
+
let target = null;
|
|
1138
|
+
if (packageSource === 'inputs') {
|
|
1139
|
+
// package_path is ignored: the package arrives as a run input.
|
|
1140
|
+
// target is optional and discovered from the shipped manifest when
|
|
1141
|
+
// the package declares exactly one library product.
|
|
1142
|
+
const configuredTarget = this.getInput(inputs, 'target', undefined);
|
|
1143
|
+
target = configuredTarget === undefined ? null : String(configuredTarget);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
|
|
1147
|
+
target = String(this.getRequiredInput(inputs, 'target', stepName));
|
|
1148
|
+
}
|
|
1149
|
+
if (target !== null && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
|
|
601
1150
|
throw new Error(`Invalid target '${target}' for step '${stepName}': ` +
|
|
602
1151
|
'must be a Swift module identifier (letters, digits, underscores)');
|
|
603
1152
|
}
|
|
604
1153
|
const { width, height } = parseRenderSize(String(this.getInput(inputs, 'render_size', DEFAULT_RENDER_SIZE)));
|
|
605
1154
|
const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
|
|
606
|
-
const
|
|
1155
|
+
const config = { packagePath, target, width, height, scale };
|
|
1156
|
+
if (packageSource !== undefined) {
|
|
1157
|
+
config.packageSource = packageSource;
|
|
1158
|
+
}
|
|
1159
|
+
const script = generateRenderScript(config);
|
|
607
1160
|
return this.createNodeStep(script, stepName);
|
|
608
1161
|
}
|
|
609
1162
|
}
|