agent-scenario-loop 0.1.3 → 0.1.4
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/app/profile-session.ts +263 -17
- package/dist/core/artifact-contract.d.ts +6 -4
- package/dist/core/artifact-contract.js +164 -15
- package/dist/core/schema-validator.d.ts +1 -0
- package/dist/core/schema-validator.js +1 -0
- package/dist/runner/android-adb-driver.d.ts +7 -2
- package/dist/runner/android-adb-driver.js +7 -1
- package/dist/runner/android-adb.d.ts +40 -5
- package/dist/runner/android-adb.js +1046 -664
- package/dist/runner/ios-simctl.d.ts +1 -0
- package/dist/runner/ios-simctl.js +1 -0
- package/dist/runner/profile-android.d.ts +11 -1
- package/dist/runner/profile-android.js +230 -16
- package/dist/runner/profile-ios.d.ts +3 -2
- package/dist/runner/profile-ios.js +223 -20
- package/dist/runner/profile-mobile.d.ts +31 -3
- package/dist/runner/profile-mobile.js +793 -20
- package/dist/runner/validate-project.js +3 -0
- package/dist/scripts/consumer-rehearsal.d.ts +119 -0
- package/dist/scripts/consumer-rehearsal.js +757 -0
- package/dist/scripts/downstream-local-package-gate.d.ts +2 -0
- package/dist/scripts/downstream-local-package-gate.js +264 -0
- package/dist/scripts/package-smoke.d.ts +96 -0
- package/dist/scripts/package-smoke.js +2282 -0
- package/dist/scripts/release-readiness.d.ts +2 -0
- package/dist/scripts/release-readiness.js +520 -0
- package/docs/adapters.md +3 -1
- package/docs/api.md +2 -2
- package/docs/authoring.md +34 -2
- package/docs/consumer-rehearsal.md +27 -1
- package/docs/contracts.md +16 -2
- package/docs/live-proofs.md +5 -3
- package/examples/mobile-app/runner-manifests/evidence-provider.json +3 -3
- package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +25 -0
- package/examples/runners/README.md +3 -3
- package/examples/runners/axe-accessibility-provider.json +2 -2
- package/examples/runners/script-accessibility-provider.json +2 -2
- package/examples/runners/script-memory-provider.json +2 -2
- package/examples/runners/script-network-provider.json +2 -2
- package/examples/runners/script-profiler-provider.json +2 -2
- package/package.json +11 -3
- package/schemas/manifest.schema.json +73 -3
- package/schemas/profiler.schema.json +243 -0
- package/schemas/runner-capabilities.schema.json +8 -2
- package/schemas/scenario.schema.json +18 -2
- package/templates/evidence-provider.json +3 -3
- package/templates/scripts/asl-capture-profiler-provider.mjs +20 -0
|
@@ -49,7 +49,7 @@ function usage({ binaryName, output = process.stderr, platform, }) {
|
|
|
49
49
|
];
|
|
50
50
|
if (platform === 'android') {
|
|
51
51
|
lines.push('Use --adb-artifacts <dir> to read raw/adb-logcat.txt from a prior asl-android-adb capture.');
|
|
52
|
-
lines.push('Use --adb-capture [--clear-logcat] [--launch] [--launch-wait-ms <ms>] [--wait-ms <ms>] to capture adb logcat before profiling.');
|
|
52
|
+
lines.push('Use --adb-capture [--clear-logcat] [--launch] [--launch-wait-ms <ms>] [--wait-ms <ms>] [--adb-command-timeout-ms <ms>] to capture adb logcat before profiling.');
|
|
53
53
|
lines.push('Use --android-dev-client-url <url> [--android-dev-client-wait-ms <ms>] [--android-dev-client-ready-pattern <pattern>] with --adb-capture to open an Expo dev-client session before profile-session deep links.');
|
|
54
54
|
lines.push('Use --android-profile-session-storage with --profile-session to seed startup control through Android AsyncStorage.');
|
|
55
55
|
lines.push('Use --profile-session with --adb-capture to start the app profile session and execute scenario-declared Android commands.');
|
|
@@ -251,6 +251,8 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
|
|
|
251
251
|
destinationPath: path.join(layout.signals[kind], fileName),
|
|
252
252
|
kind,
|
|
253
253
|
manifestPath: `signals/${kind}/${fileName}`,
|
|
254
|
+
providerId,
|
|
255
|
+
...(typeof output.required === 'boolean' ? { required: output.required } : {}),
|
|
254
256
|
sourcePath,
|
|
255
257
|
};
|
|
256
258
|
}
|
|
@@ -263,6 +265,8 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
|
|
|
263
265
|
destinationPath: path.join(layout.captures, fileName),
|
|
264
266
|
kind: output.kind,
|
|
265
267
|
manifestPath: `captures/${fileName}`,
|
|
268
|
+
providerId,
|
|
269
|
+
...(typeof output.required === 'boolean' ? { required: output.required } : {}),
|
|
266
270
|
sourcePath,
|
|
267
271
|
};
|
|
268
272
|
}
|
|
@@ -274,9 +278,33 @@ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, })
|
|
|
274
278
|
destinationPath: path.join(layout.raw, 'providers', providerId, fileName),
|
|
275
279
|
kind: output.kind,
|
|
276
280
|
manifestPath: `raw/providers/${providerId}/${fileName}`,
|
|
281
|
+
providerId,
|
|
282
|
+
...(typeof output.required === 'boolean' ? { required: output.required } : {}),
|
|
277
283
|
sourcePath,
|
|
278
284
|
};
|
|
279
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Validates structured profiler evidence when a provider emits JSON.
|
|
288
|
+
*
|
|
289
|
+
* Native traces and flamegraph files may be attached as profiler evidence, but
|
|
290
|
+
* JSON profiler files must carry enough envelope metadata for agents to reason
|
|
291
|
+
* about source, target, and completeness.
|
|
292
|
+
*
|
|
293
|
+
* @param {{kind: EvidenceKind, sourcePath: string}} options
|
|
294
|
+
* @returns {void}
|
|
295
|
+
*/
|
|
296
|
+
function validateStructuredProfilerEvidence({ kind, sourcePath, }) {
|
|
297
|
+
if (kind !== 'profiler' || path.extname(sourcePath).toLowerCase() !== '.json') {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
assertValidJson(readJson(sourcePath), SCHEMAS.profiler, 'Profiler evidence artifact');
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
305
|
+
throw new Error(`Profiler evidence artifact is invalid: ${sourcePath}. ${detail}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
280
308
|
/**
|
|
281
309
|
* Fails when provider command ids would collide in raw command records.
|
|
282
310
|
*
|
|
@@ -442,7 +470,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
|
|
|
442
470
|
},
|
|
443
471
|
};
|
|
444
472
|
const destinationPaths = new Set();
|
|
445
|
-
const addCopy = async ({ channel, destinationPath, kind, manifestPath, sourcePath, }) => {
|
|
473
|
+
const addCopy = async ({ channel, destinationPath, kind, manifestPath, required = false, sourcePath, }) => {
|
|
446
474
|
const stat = await fsp.stat(sourcePath).catch(() => null);
|
|
447
475
|
if (!stat?.isFile()) {
|
|
448
476
|
throw new Error(`Evidence artifact does not exist or is not a file: ${sourcePath}`);
|
|
@@ -450,6 +478,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
|
|
|
450
478
|
if (destinationPaths.has(destinationPath)) {
|
|
451
479
|
throw new Error(`Duplicate evidence artifact destination: ${manifestPath}`);
|
|
452
480
|
}
|
|
481
|
+
validateStructuredProfilerEvidence({ kind, sourcePath });
|
|
453
482
|
destinationPaths.add(destinationPath);
|
|
454
483
|
const attachment = {
|
|
455
484
|
channel,
|
|
@@ -459,6 +488,7 @@ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
|
|
|
459
488
|
kind,
|
|
460
489
|
manifestPath,
|
|
461
490
|
redactionStatus: 'not-redacted',
|
|
491
|
+
required,
|
|
462
492
|
sha256: await hashFileSha256(sourcePath),
|
|
463
493
|
sourceFileName: path.basename(sourcePath),
|
|
464
494
|
sourcePath,
|
|
@@ -578,20 +608,447 @@ function toPortablePathReference(targetPath) {
|
|
|
578
608
|
}
|
|
579
609
|
return path.basename(targetPath);
|
|
580
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* Returns a path reference from one run folder to an external sidecar.
|
|
613
|
+
*
|
|
614
|
+
* @param {{runDir: string, targetPath: string}} options
|
|
615
|
+
* @returns {string}
|
|
616
|
+
*/
|
|
617
|
+
function toRunPathReference({ runDir, targetPath }) {
|
|
618
|
+
const relativePath = path.relative(runDir, targetPath);
|
|
619
|
+
return relativePath.length > 0 ? relativePath : path.basename(targetPath);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Returns a sidecar dependency path that stays readable in rehydrated artifacts.
|
|
623
|
+
*
|
|
624
|
+
* @param {{runDir: string, sidecarRoot: string, targetPath: string}} options
|
|
625
|
+
* @returns {SidecarEvidenceDependency}
|
|
626
|
+
*/
|
|
627
|
+
function toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath, }) {
|
|
628
|
+
const sidecarRelativePath = path.relative(sidecarRoot, targetPath);
|
|
629
|
+
if (sidecarRelativePath.length > 0 &&
|
|
630
|
+
!sidecarRelativePath.startsWith('..') &&
|
|
631
|
+
!path.isAbsolute(sidecarRelativePath)) {
|
|
632
|
+
return {
|
|
633
|
+
kind: 'sidecar',
|
|
634
|
+
root: 'sidecar',
|
|
635
|
+
path: sidecarRelativePath,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
kind: 'sidecar',
|
|
640
|
+
path: toRunPathReference({ runDir, targetPath }),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Reads scenario string-list declarations into a set.
|
|
645
|
+
*
|
|
646
|
+
* @param {Record<string, unknown>} scenario
|
|
647
|
+
* @param {string[]} pathSegments
|
|
648
|
+
* @returns {Set<string>}
|
|
649
|
+
*/
|
|
650
|
+
function readScenarioStringSet(scenario, pathSegments) {
|
|
651
|
+
const values = pathSegments.reduce((current, segment) => (current && typeof current === 'object' && !Array.isArray(current)
|
|
652
|
+
? current[segment]
|
|
653
|
+
: undefined), scenario);
|
|
654
|
+
return new Set(Array.isArray(values)
|
|
655
|
+
? values.filter((value) => typeof value === 'string')
|
|
656
|
+
: []);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Returns true when a scenario artifact declaration matches a diagnostic kind.
|
|
660
|
+
*
|
|
661
|
+
* @param {Set<string>} artifacts
|
|
662
|
+
* @param {string[]} aliases
|
|
663
|
+
* @returns {boolean}
|
|
664
|
+
*/
|
|
665
|
+
function artifactSetHasAny(artifacts, aliases) {
|
|
666
|
+
return aliases.some((alias) => artifacts.has(alias));
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Resolves common aliases used by scenario artifact contracts.
|
|
670
|
+
*
|
|
671
|
+
* @param {DiagnosticKind} kind
|
|
672
|
+
* @returns {string[]}
|
|
673
|
+
*/
|
|
674
|
+
function diagnosticArtifactAliases(kind) {
|
|
675
|
+
const aliases = {
|
|
676
|
+
accessibility: ['accessibility'],
|
|
677
|
+
js: ['js', 'profileEvents', 'profileSession'],
|
|
678
|
+
logs: ['logs', 'deviceLog', 'interactionLog'],
|
|
679
|
+
memory: ['memory'],
|
|
680
|
+
network: ['network'],
|
|
681
|
+
profiler: ['profiler', 'profile'],
|
|
682
|
+
screenshot: ['screenshot', 'screenshots'],
|
|
683
|
+
uiTree: ['uiTree', 'ui-tree', 'accessibilityTree'],
|
|
684
|
+
video: ['video', 'recording'],
|
|
685
|
+
};
|
|
686
|
+
return aliases[kind];
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Resolves common aliases used by runner capability declarations.
|
|
690
|
+
*
|
|
691
|
+
* @param {DiagnosticKind} kind
|
|
692
|
+
* @returns {string[]}
|
|
693
|
+
*/
|
|
694
|
+
function diagnosticCapabilityAliases(kind) {
|
|
695
|
+
const aliases = {
|
|
696
|
+
accessibility: ['accessibility', 'accessibilityCapture'],
|
|
697
|
+
js: ['js', 'profileSession', 'profileEvents'],
|
|
698
|
+
logs: ['logCapture', 'logs', 'deviceLog'],
|
|
699
|
+
memory: ['memory', 'memoryCapture'],
|
|
700
|
+
network: ['network', 'networkCapture'],
|
|
701
|
+
profiler: ['profiler', 'profile', 'profilerCapture'],
|
|
702
|
+
screenshot: ['screenshot', 'screenshots'],
|
|
703
|
+
uiTree: ['uiTree', 'ui-tree', 'accessibilityTree'],
|
|
704
|
+
video: ['video', 'recording'],
|
|
705
|
+
};
|
|
706
|
+
return aliases[kind];
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Returns requirement/request metadata for one diagnostic kind.
|
|
710
|
+
*
|
|
711
|
+
* @param {{kind: DiagnosticKind, optionalArtifacts: Set<string>, optionalCapabilities: Set<string>, requiredArtifacts: Set<string>, requiredCapabilities: Set<string>}} options
|
|
712
|
+
* @returns {{required: boolean, requested: boolean}}
|
|
713
|
+
*/
|
|
714
|
+
function resolveDiagnosticRequest({ kind, optionalArtifacts, optionalCapabilities, requiredArtifacts, requiredCapabilities, }) {
|
|
715
|
+
const artifactAliases = diagnosticArtifactAliases(kind);
|
|
716
|
+
const capabilityAliases = diagnosticCapabilityAliases(kind);
|
|
717
|
+
const required = artifactSetHasAny(requiredArtifacts, artifactAliases) ||
|
|
718
|
+
artifactSetHasAny(requiredCapabilities, capabilityAliases);
|
|
719
|
+
return {
|
|
720
|
+
required,
|
|
721
|
+
requested: required ||
|
|
722
|
+
artifactSetHasAny(optionalArtifacts, artifactAliases) ||
|
|
723
|
+
artifactSetHasAny(optionalCapabilities, capabilityAliases),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Builds a status entry for one diagnostic surface.
|
|
728
|
+
*
|
|
729
|
+
* @param {DiagnosticInventoryEntry & {requested?: boolean}} entry
|
|
730
|
+
* @returns {DiagnosticInventoryEntry}
|
|
731
|
+
*/
|
|
732
|
+
function buildDiagnosticEntry(entry) {
|
|
733
|
+
const { requested = true, ...diagnostic } = entry;
|
|
734
|
+
if (diagnostic.status === 'captured' || requested || diagnostic.required) {
|
|
735
|
+
return diagnostic;
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
...diagnostic,
|
|
739
|
+
status: 'not_requested',
|
|
740
|
+
reason: diagnostic.reason ?? 'Scenario did not request this optional diagnostic surface.',
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Builds the product-neutral diagnostic inventory for a profile run.
|
|
745
|
+
*
|
|
746
|
+
* @param {{args: CliArgs, attachedEvidence: AttachedEvidence, eventLogPath: string | null, platform: ProfilePlatform, profileSessionEntriesPath: string | null, runDir: string, scenario: Record<string, unknown>}} options
|
|
747
|
+
* @returns {DiagnosticInventoryEntry[]}
|
|
748
|
+
*/
|
|
749
|
+
function buildDiagnosticInventory({ args, attachedEvidence, eventLogPath, platform, profileSessionEntriesPath, runDir, scenario, }) {
|
|
750
|
+
const requiredArtifacts = readScenarioStringSet(scenario, ['artifacts', 'required']);
|
|
751
|
+
const optionalArtifacts = readScenarioStringSet(scenario, ['artifacts', 'optional']);
|
|
752
|
+
const requiredCapabilities = readScenarioStringSet(scenario, ['requiredCapabilities']);
|
|
753
|
+
const optionalCapabilities = readScenarioStringSet(scenario, ['optionalCapabilities']);
|
|
754
|
+
const requiredProviderDiagnostics = new Set(attachedEvidence.attachments
|
|
755
|
+
.filter((attachment) => attachment.required)
|
|
756
|
+
.map((attachment) => attachment.kind));
|
|
757
|
+
const sidecarRoot = typeof args['adb-artifacts'] === 'string'
|
|
758
|
+
? path.resolve(args['adb-artifacts'])
|
|
759
|
+
: typeof args['simctl-artifacts'] === 'string'
|
|
760
|
+
? path.resolve(args['simctl-artifacts'])
|
|
761
|
+
: null;
|
|
762
|
+
const sidecarRootRef = sidecarRoot ? toRunPathReference({ runDir, targetPath: sidecarRoot }) : undefined;
|
|
763
|
+
const adbScreenshotDependency = platform === 'android'
|
|
764
|
+
? resolveAndroidAdbScreenshotDependency({ runDir, sidecarRoot })
|
|
765
|
+
: null;
|
|
766
|
+
const eventLogBaseName = eventLogPath ? path.basename(eventLogPath) : undefined;
|
|
767
|
+
const eventLogManifestPath = eventLogBaseName ? `raw/${eventLogBaseName}` : undefined;
|
|
768
|
+
const eventLogIsIosProfileEvents = platform === 'ios' && eventLogBaseName === 'ios-profile-events.log';
|
|
769
|
+
const simctlRuntimeLogPath = typeof args['simctl-artifacts'] === 'string'
|
|
770
|
+
? path.resolve(args['simctl-artifacts'], 'raw', 'ios-simctl-log.txt')
|
|
771
|
+
: null;
|
|
772
|
+
const simctlRuntimeLogExists = Boolean(simctlRuntimeLogPath && fs.existsSync(simctlRuntimeLogPath));
|
|
773
|
+
const simctlRuntimeLogDependency = simctlRuntimeLogPath && simctlRuntimeLogExists
|
|
774
|
+
? toSidecarEvidenceDependency({ runDir, sidecarRoot: path.resolve(args['simctl-artifacts']), targetPath: simctlRuntimeLogPath })
|
|
775
|
+
: undefined;
|
|
776
|
+
const copiedSimctlLogManifestPath = platform === 'ios' && eventLogPath && path.basename(eventLogPath) === 'ios-simctl-log.txt'
|
|
777
|
+
? eventLogManifestPath
|
|
778
|
+
: undefined;
|
|
779
|
+
const explicitIosRuntimeLogManifestPath = platform === 'ios' &&
|
|
780
|
+
typeof args.events === 'string' &&
|
|
781
|
+
eventLogManifestPath &&
|
|
782
|
+
!eventLogIsIosProfileEvents
|
|
783
|
+
? eventLogManifestPath
|
|
784
|
+
: undefined;
|
|
785
|
+
const eventLogDependency = eventLogPath && sidecarRoot
|
|
786
|
+
? toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath: eventLogPath })
|
|
787
|
+
: undefined;
|
|
788
|
+
const jsProfilePath = attachedEvidence.signals.js[0] ?? eventLogManifestPath;
|
|
789
|
+
const profileSessionEntriesManifestPath = profileSessionEntriesPath
|
|
790
|
+
? `raw/${path.basename(profileSessionEntriesPath)}`
|
|
791
|
+
: undefined;
|
|
792
|
+
const entries = [];
|
|
793
|
+
const pushDiagnostic = (kind, entry) => {
|
|
794
|
+
const request = resolveDiagnosticRequest({
|
|
795
|
+
kind,
|
|
796
|
+
optionalArtifacts,
|
|
797
|
+
optionalCapabilities,
|
|
798
|
+
requiredArtifacts,
|
|
799
|
+
requiredCapabilities,
|
|
800
|
+
});
|
|
801
|
+
entries.push(buildDiagnosticEntry({
|
|
802
|
+
kind,
|
|
803
|
+
...entry,
|
|
804
|
+
required: request.required || requiredProviderDiagnostics.has(kind) || Boolean(entry.required),
|
|
805
|
+
requested: request.requested || requiredProviderDiagnostics.has(kind) || Boolean(entry.requested),
|
|
806
|
+
}));
|
|
807
|
+
};
|
|
808
|
+
const logCaptured = platform === 'ios'
|
|
809
|
+
? Boolean(copiedSimctlLogManifestPath || simctlRuntimeLogDependency || explicitIosRuntimeLogManifestPath)
|
|
810
|
+
: Boolean(eventLogManifestPath);
|
|
811
|
+
pushDiagnostic('logs', {
|
|
812
|
+
name: platform === 'ios' ? 'simulator-runtime-log' : 'device-log',
|
|
813
|
+
...(typeof args['adb-artifacts'] === 'string'
|
|
814
|
+
? { provider: 'adb', runnerId: 'android-adb' }
|
|
815
|
+
: typeof args['simctl-artifacts'] === 'string'
|
|
816
|
+
? { provider: 'simctl', runnerId: 'ios-simctl' }
|
|
817
|
+
: typeof args.events === 'string'
|
|
818
|
+
? { provider: 'fixture-log-ingest' }
|
|
819
|
+
: {}),
|
|
820
|
+
status: logCaptured ? 'captured' : 'unavailable',
|
|
821
|
+
...(platform === 'ios'
|
|
822
|
+
? copiedSimctlLogManifestPath
|
|
823
|
+
? { path: copiedSimctlLogManifestPath }
|
|
824
|
+
: simctlRuntimeLogDependency
|
|
825
|
+
? { path: simctlRuntimeLogDependency.path }
|
|
826
|
+
: explicitIosRuntimeLogManifestPath
|
|
827
|
+
? { path: explicitIosRuntimeLogManifestPath }
|
|
828
|
+
: {}
|
|
829
|
+
: eventLogManifestPath
|
|
830
|
+
? { path: eventLogManifestPath }
|
|
831
|
+
: {}),
|
|
832
|
+
...(sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
|
|
833
|
+
...(platform === 'ios'
|
|
834
|
+
? simctlRuntimeLogDependency
|
|
835
|
+
? { evidenceDependency: simctlRuntimeLogDependency }
|
|
836
|
+
: {}
|
|
837
|
+
: eventLogDependency
|
|
838
|
+
? { evidenceDependency: eventLogDependency }
|
|
839
|
+
: {}),
|
|
840
|
+
...(logCaptured
|
|
841
|
+
? {
|
|
842
|
+
reason: platform === 'ios'
|
|
843
|
+
? 'iOS simulator runtime log evidence was available from the simctl capture sidecar.'
|
|
844
|
+
: 'Device or fixture log evidence was available to the profile runner.',
|
|
845
|
+
}
|
|
846
|
+
: {
|
|
847
|
+
reason: platform === 'ios'
|
|
848
|
+
? 'No iOS simulator runtime log was available in the selected simctl capture sidecar.'
|
|
849
|
+
: 'No device log source was supplied to this profile run.',
|
|
850
|
+
nextAction: platform === 'ios'
|
|
851
|
+
? 'Run with --simctl-capture or provide --simctl-artifacts containing raw/ios-simctl-log.txt.'
|
|
852
|
+
: 'Run with --events, --adb-artifacts, --adb-capture, or provide a runtime log artifact.',
|
|
853
|
+
}),
|
|
854
|
+
});
|
|
855
|
+
pushDiagnostic('js', {
|
|
856
|
+
name: 'profile-session-evidence',
|
|
857
|
+
status: eventLogManifestPath || attachedEvidence.signals.js.length > 0 ? 'captured' : 'unavailable',
|
|
858
|
+
...(jsProfilePath ? { path: jsProfilePath } : {}),
|
|
859
|
+
...(profileSessionEntriesManifestPath
|
|
860
|
+
? {
|
|
861
|
+
evidenceDependency: {
|
|
862
|
+
kind: 'profile-session-entries',
|
|
863
|
+
path: profileSessionEntriesManifestPath,
|
|
864
|
+
},
|
|
865
|
+
}
|
|
866
|
+
: eventLogDependency
|
|
867
|
+
? { evidenceDependency: eventLogDependency }
|
|
868
|
+
: {}),
|
|
869
|
+
...(sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
|
|
870
|
+
...(eventLogManifestPath || attachedEvidence.signals.js.length > 0
|
|
871
|
+
? { reason: 'Profile or JS evidence was captured from runner input.' }
|
|
872
|
+
: {
|
|
873
|
+
reason: 'No profile-session event log or JS signal attachment was available.',
|
|
874
|
+
nextAction: 'Attach JS evidence with --signal js:<path> or run a profile-session capture that emits profile events.',
|
|
875
|
+
}),
|
|
876
|
+
});
|
|
877
|
+
const attachedScreenshotPath = attachedEvidence.captures.screenshots[0];
|
|
878
|
+
const sidecarScreenshotDependency = attachedScreenshotPath ? null : adbScreenshotDependency;
|
|
879
|
+
pushDiagnostic('screenshot', {
|
|
880
|
+
...(sidecarScreenshotDependency ? { provider: 'adb', runnerId: 'android-adb' } : {}),
|
|
881
|
+
status: attachedScreenshotPath || sidecarScreenshotDependency ? 'captured' : 'unavailable',
|
|
882
|
+
...(attachedScreenshotPath
|
|
883
|
+
? { path: attachedScreenshotPath }
|
|
884
|
+
: sidecarScreenshotDependency
|
|
885
|
+
? { path: sidecarScreenshotDependency.path }
|
|
886
|
+
: {}),
|
|
887
|
+
...(sidecarScreenshotDependency && sidecarRootRef ? { sidecarRoot: sidecarRootRef } : {}),
|
|
888
|
+
...(sidecarScreenshotDependency ? { evidenceDependency: sidecarScreenshotDependency.dependency } : {}),
|
|
889
|
+
...(attachedScreenshotPath || sidecarScreenshotDependency
|
|
890
|
+
? {
|
|
891
|
+
reason: sidecarScreenshotDependency
|
|
892
|
+
? 'Screenshot evidence was available from the adb capture sidecar.'
|
|
893
|
+
: 'Screenshot capture was attached to the run.',
|
|
894
|
+
}
|
|
895
|
+
: {
|
|
896
|
+
reason: 'No screenshot capture was produced by the selected runner/provider set.',
|
|
897
|
+
nextAction: 'Use --capture screenshot:<path> or a runner/provider that produces screenshots.',
|
|
898
|
+
}),
|
|
899
|
+
});
|
|
900
|
+
pushDiagnostic('uiTree', {
|
|
901
|
+
status: attachedEvidence.captures.uiTree ? 'captured' : 'unavailable',
|
|
902
|
+
...(attachedEvidence.captures.uiTree ? { path: attachedEvidence.captures.uiTree } : {}),
|
|
903
|
+
...(attachedEvidence.captures.uiTree
|
|
904
|
+
? { reason: 'UI tree capture was attached to the run.' }
|
|
905
|
+
: {
|
|
906
|
+
reason: 'No UI tree capture was produced by the selected runner/provider set.',
|
|
907
|
+
nextAction: 'Use --capture uiTree:<path> or add an accessibility/UI-tree provider.',
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
pushDiagnostic('video', {
|
|
911
|
+
status: attachedEvidence.captures.video ? 'captured' : 'unavailable',
|
|
912
|
+
...(attachedEvidence.captures.video ? { path: attachedEvidence.captures.video } : {}),
|
|
913
|
+
...(attachedEvidence.captures.video
|
|
914
|
+
? { reason: 'Video capture was attached to the run.' }
|
|
915
|
+
: {
|
|
916
|
+
reason: 'No video capture was produced by the selected runner/provider set.',
|
|
917
|
+
nextAction: 'Use --capture video:<path> or run a capture provider that records video.',
|
|
918
|
+
}),
|
|
919
|
+
});
|
|
920
|
+
for (const kind of ['memory', 'network']) {
|
|
921
|
+
pushDiagnostic(kind, {
|
|
922
|
+
status: attachedEvidence.signals[kind].length > 0 ? 'captured' : 'unavailable',
|
|
923
|
+
...(attachedEvidence.signals[kind][0] ? { path: attachedEvidence.signals[kind][0] } : {}),
|
|
924
|
+
...(attachedEvidence.signals[kind].length > 0
|
|
925
|
+
? { reason: `${kind} signal evidence was attached to the run.` }
|
|
926
|
+
: {
|
|
927
|
+
reason: `No ${kind} signal evidence was produced by the selected provider set.`,
|
|
928
|
+
nextAction: `Attach ${kind} evidence with --signal ${kind}:<path> or add a provider command that emits it.`,
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
for (const kind of ['accessibility', 'profiler']) {
|
|
933
|
+
const attachment = attachedEvidence.attachments.find((item) => item.kind === kind);
|
|
934
|
+
pushDiagnostic(kind, {
|
|
935
|
+
...(attachment?.channel === 'provider' ? { provider: 'evidence-provider' } : {}),
|
|
936
|
+
status: attachment ? 'captured' : 'unavailable',
|
|
937
|
+
...(attachment ? { path: attachment.manifestPath } : {}),
|
|
938
|
+
...(attachment
|
|
939
|
+
? { reason: `${kind} provider evidence was attached to the run.` }
|
|
940
|
+
: {
|
|
941
|
+
reason: `No ${kind} provider attachment was produced by the selected provider set.`,
|
|
942
|
+
nextAction: `Declare a provider command or attach ${kind} evidence before expecting this diagnostic.`,
|
|
943
|
+
}),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return entries.map((entry) => {
|
|
947
|
+
const cleaned = Object.entries(entry).filter(([, value]) => value !== undefined);
|
|
948
|
+
return Object.fromEntries(cleaned);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Converts uncaptured required diagnostics into health checks.
|
|
953
|
+
*
|
|
954
|
+
* @param {DiagnosticInventoryEntry[]} diagnostics
|
|
955
|
+
* @returns {Record<string, unknown>[]}
|
|
956
|
+
*/
|
|
957
|
+
function buildRequiredDiagnosticHealthChecks(diagnostics = []) {
|
|
958
|
+
return diagnostics
|
|
959
|
+
.filter((diagnostic) => diagnostic.required && diagnostic.status !== 'captured')
|
|
960
|
+
.map((diagnostic) => ({
|
|
961
|
+
name: `required_${diagnostic.kind}_diagnostic`,
|
|
962
|
+
status: 'failed',
|
|
963
|
+
source: 'evidence',
|
|
964
|
+
code: 'required_diagnostic_not_captured',
|
|
965
|
+
message: diagnostic.reason ?? `Required ${diagnostic.kind} diagnostic was not captured.`,
|
|
966
|
+
metadata: {
|
|
967
|
+
kind: diagnostic.kind,
|
|
968
|
+
status: diagnostic.status,
|
|
969
|
+
...(diagnostic.name ? { name: diagnostic.name } : {}),
|
|
970
|
+
...(diagnostic.nextAction ? { nextAction: diagnostic.nextAction } : {}),
|
|
971
|
+
...(diagnostic.provider ? { provider: diagnostic.provider } : {}),
|
|
972
|
+
...(diagnostic.runnerId ? { runnerId: diagnostic.runnerId } : {}),
|
|
973
|
+
},
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
581
976
|
/**
|
|
582
977
|
* Builds scenario health from profile metrics.
|
|
583
978
|
*
|
|
584
|
-
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>}} options
|
|
979
|
+
* @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>, diagnostics?: DiagnosticInventoryEntry[], profileEventCount?: number, profileSessionEntryCount?: number, commandTransport?: string, sessionEntries?: Record<string, unknown>[]}} options
|
|
585
980
|
* @returns {Record<string, unknown>}
|
|
586
981
|
*/
|
|
587
|
-
function buildProfileHealth({ scenario, runId, metrics, }) {
|
|
982
|
+
function buildProfileHealth({ scenario, runId, metrics, diagnostics = [], profileEventCount, profileSessionEntryCount, commandTransport, sessionEntries = [], }) {
|
|
588
983
|
const passed = metrics.status === 'passed';
|
|
984
|
+
const metadata = {
|
|
985
|
+
failures: typeof metrics.failures === 'number' ? metrics.failures : null,
|
|
986
|
+
timeouts: typeof metrics.timeouts === 'number' ? metrics.timeouts : null,
|
|
987
|
+
};
|
|
988
|
+
if (typeof profileEventCount === 'number') {
|
|
989
|
+
metadata.profileEventCount = profileEventCount;
|
|
990
|
+
}
|
|
991
|
+
if (typeof profileSessionEntryCount === 'number') {
|
|
992
|
+
metadata.profileSessionEntryCount = profileSessionEntryCount;
|
|
993
|
+
}
|
|
994
|
+
if (typeof commandTransport === 'string' && commandTransport.length > 0) {
|
|
995
|
+
metadata.commandTransport = commandTransport;
|
|
996
|
+
}
|
|
997
|
+
if (!passed &&
|
|
998
|
+
profileEventCount === 0 &&
|
|
999
|
+
profileSessionEntryCount === 0 &&
|
|
1000
|
+
typeof commandTransport === 'string' &&
|
|
1001
|
+
commandTransport.startsWith('profile-session')) {
|
|
1002
|
+
metadata.nextActionCode = 'verify_profile_session_bootstrap';
|
|
1003
|
+
metadata.nextAction =
|
|
1004
|
+
'Verify the app loaded the expected bundle, mounted the profile-session bootstrap near the app root, and uses the configured storage keys or deep-link scheme before treating this as a product failure.';
|
|
1005
|
+
}
|
|
1006
|
+
const skippedCommands = sessionEntries.filter((entry) => (entry?.kind === 'command' && entry.status === 'skipped'));
|
|
1007
|
+
const firstSkippedCommand = skippedCommands[0];
|
|
1008
|
+
const firstSkippedReason = typeof firstSkippedCommand?.reason === 'string'
|
|
1009
|
+
? firstSkippedCommand.reason
|
|
1010
|
+
: undefined;
|
|
1011
|
+
const commandFailureCode = firstSkippedReason === 'wait-for-milestone-timeout'
|
|
1012
|
+
? 'profile_command_gate_timeout'
|
|
1013
|
+
: 'profile_command_skipped';
|
|
1014
|
+
const commandFailureMessage = firstSkippedReason === 'wait-for-milestone-timeout'
|
|
1015
|
+
? 'One or more profile-session commands waited for a milestone that was not observed before timeout.'
|
|
1016
|
+
: 'One or more profile-session commands were skipped before the scenario completed.';
|
|
1017
|
+
const commandChecks = skippedCommands.length > 0
|
|
1018
|
+
? [
|
|
1019
|
+
{
|
|
1020
|
+
name: 'profile_command_sequence',
|
|
1021
|
+
status: 'failed',
|
|
1022
|
+
source: 'runner',
|
|
1023
|
+
code: commandFailureCode,
|
|
1024
|
+
message: commandFailureMessage,
|
|
1025
|
+
metadata: {
|
|
1026
|
+
skippedCommandCount: skippedCommands.length,
|
|
1027
|
+
...(typeof firstSkippedCommand?.command === 'string' ? { command: firstSkippedCommand.command } : {}),
|
|
1028
|
+
...(typeof firstSkippedCommand?.commandId === 'string' ? { commandId: firstSkippedCommand.commandId } : {}),
|
|
1029
|
+
...(typeof firstSkippedCommand?.queueId === 'string' ? { queueId: firstSkippedCommand.queueId } : {}),
|
|
1030
|
+
...(typeof firstSkippedCommand?.reason === 'string' ? { reason: firstSkippedCommand.reason } : {}),
|
|
1031
|
+
...(typeof firstSkippedCommand?.sequence === 'number' ? { sequence: firstSkippedCommand.sequence } : {}),
|
|
1032
|
+
...(typeof firstSkippedCommand?.waitForMilestone === 'string'
|
|
1033
|
+
? { waitForMilestone: firstSkippedCommand.waitForMilestone }
|
|
1034
|
+
: {}),
|
|
1035
|
+
...(typeof firstSkippedCommand?.waitTimeoutMs === 'number'
|
|
1036
|
+
? { waitTimeoutMs: firstSkippedCommand.waitTimeoutMs }
|
|
1037
|
+
: {}),
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
]
|
|
1041
|
+
: [];
|
|
1042
|
+
const commandChecksPassed = commandChecks.every((check) => check.status === 'passed');
|
|
1043
|
+
const diagnosticChecks = buildRequiredDiagnosticHealthChecks(diagnostics);
|
|
1044
|
+
const diagnosticChecksPassed = diagnosticChecks.every((check) => check.status === 'passed');
|
|
1045
|
+
const healthPassed = passed && commandChecksPassed && diagnosticChecksPassed;
|
|
589
1046
|
return assertValidJson({
|
|
590
1047
|
schemaVersion: '1.0.0',
|
|
591
1048
|
scenarioId: scenario.name,
|
|
592
1049
|
...(typeof scenario.flowId === 'string' ? { flowId: scenario.flowId } : {}),
|
|
593
1050
|
runId,
|
|
594
|
-
healthStatus:
|
|
1051
|
+
healthStatus: healthPassed ? 'passed' : 'failed',
|
|
595
1052
|
checks: [
|
|
596
1053
|
{
|
|
597
1054
|
name: 'truth_events_complete',
|
|
@@ -601,11 +1058,10 @@ function buildProfileHealth({ scenario, runId, metrics, }) {
|
|
|
601
1058
|
message: passed
|
|
602
1059
|
? 'Profile events completed every expected iteration.'
|
|
603
1060
|
: 'Profile events did not complete every expected iteration.',
|
|
604
|
-
metadata
|
|
605
|
-
failures: typeof metrics.failures === 'number' ? metrics.failures : null,
|
|
606
|
-
timeouts: typeof metrics.timeouts === 'number' ? metrics.timeouts : null,
|
|
607
|
-
},
|
|
1061
|
+
metadata,
|
|
608
1062
|
},
|
|
1063
|
+
...commandChecks,
|
|
1064
|
+
...diagnosticChecks,
|
|
609
1065
|
],
|
|
610
1066
|
}, SCHEMAS.health, 'Health artifact');
|
|
611
1067
|
}
|
|
@@ -857,6 +1313,121 @@ function resolveProfileSessionEntriesPath({ args, platform }) {
|
|
|
857
1313
|
}
|
|
858
1314
|
return null;
|
|
859
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Resolves the run id used by rehydrated sidecar evidence.
|
|
1318
|
+
*
|
|
1319
|
+
* A rehydrated artifact can intentionally have a new run id while ingesting a
|
|
1320
|
+
* previously captured adb/simctl sidecar. Keep live runs strict, but allow an
|
|
1321
|
+
* explicit sidecar with exactly one source run id for the scenario to provide
|
|
1322
|
+
* the event filter.
|
|
1323
|
+
*
|
|
1324
|
+
* @param {{args: CliArgs, eventLogText: string, profileSessionEntriesPath: string | null, runId: string, scenarioName: string}} options
|
|
1325
|
+
* @returns {string}
|
|
1326
|
+
*/
|
|
1327
|
+
function resolveEvidenceFilterRunId({ args, eventLogText, profileSessionEntriesPath, runId, scenarioName, }) {
|
|
1328
|
+
const isRehydratedSidecar = typeof args['adb-artifacts'] === 'string' || typeof args['simctl-artifacts'] === 'string';
|
|
1329
|
+
if (!isRehydratedSidecar) {
|
|
1330
|
+
return runId;
|
|
1331
|
+
}
|
|
1332
|
+
const scenarioEvents = extractProfileEvents(eventLogText, { scenario: scenarioName });
|
|
1333
|
+
const currentRunEvents = scenarioEvents.filter((event) => event.runId === runId);
|
|
1334
|
+
if (currentRunEvents.length > 0) {
|
|
1335
|
+
return runId;
|
|
1336
|
+
}
|
|
1337
|
+
const sourceRunIds = new Set(scenarioEvents
|
|
1338
|
+
.map((event) => event.runId)
|
|
1339
|
+
.filter((sourceRunId) => typeof sourceRunId === 'string' && sourceRunId.length > 0));
|
|
1340
|
+
if (profileSessionEntriesPath && fs.existsSync(profileSessionEntriesPath)) {
|
|
1341
|
+
const storedEntries = JSON.parse(fs.readFileSync(profileSessionEntriesPath, 'utf8'));
|
|
1342
|
+
if (Array.isArray(storedEntries)) {
|
|
1343
|
+
for (const entry of storedEntries) {
|
|
1344
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
const record = entry;
|
|
1348
|
+
if (record.scenario !== scenarioName) {
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (typeof record.runId === 'string' && record.runId.length > 0) {
|
|
1352
|
+
sourceRunIds.add(record.runId);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return sourceRunIds.size === 1 ? [...sourceRunIds][0] : runId;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Returns the first usable adb screenshot file from sidecar metadata.
|
|
1361
|
+
*
|
|
1362
|
+
* ADB can produce a valid PNG even when command metadata records a nonzero
|
|
1363
|
+
* exit status from the host process. Treat the binary artifact as the capture
|
|
1364
|
+
* authority, but only after validating the PNG signature and sidecar boundary.
|
|
1365
|
+
*
|
|
1366
|
+
* @param {{runDir: string, sidecarRoot: string | null}} options
|
|
1367
|
+
* @returns {{dependency: SidecarEvidenceDependency, path: string} | null}
|
|
1368
|
+
*/
|
|
1369
|
+
function resolveAndroidAdbScreenshotDependency({ runDir, sidecarRoot, }) {
|
|
1370
|
+
if (!sidecarRoot) {
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
const metadata = readOptionalJsonObject(path.resolve(sidecarRoot, 'raw', 'android-metadata.json'));
|
|
1374
|
+
const actions = Array.isArray(metadata?.driverActions) ? metadata.driverActions : [];
|
|
1375
|
+
for (const action of actions) {
|
|
1376
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const record = action;
|
|
1380
|
+
if (record.driverAction !== 'screenshot') {
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const sidecarRelativePath = typeof record.capturePath === 'string'
|
|
1384
|
+
? record.capturePath
|
|
1385
|
+
: typeof record.rawPath === 'string'
|
|
1386
|
+
? record.rawPath
|
|
1387
|
+
: null;
|
|
1388
|
+
if (!sidecarRelativePath || path.isAbsolute(sidecarRelativePath)) {
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
const screenshotPath = path.resolve(sidecarRoot, sidecarRelativePath);
|
|
1392
|
+
const relativeToSidecar = path.relative(sidecarRoot, screenshotPath);
|
|
1393
|
+
if (relativeToSidecar.length === 0 ||
|
|
1394
|
+
relativeToSidecar.startsWith('..') ||
|
|
1395
|
+
path.isAbsolute(relativeToSidecar) ||
|
|
1396
|
+
!isPngFile(screenshotPath)) {
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
const sidecarDependency = toSidecarEvidenceDependency({ runDir, sidecarRoot, targetPath: screenshotPath });
|
|
1400
|
+
return {
|
|
1401
|
+
dependency: sidecarDependency,
|
|
1402
|
+
path: sidecarDependency.path,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Checks whether a file starts with the PNG signature.
|
|
1409
|
+
*
|
|
1410
|
+
* @param {string} filePath
|
|
1411
|
+
* @returns {boolean}
|
|
1412
|
+
*/
|
|
1413
|
+
function isPngFile(filePath) {
|
|
1414
|
+
let signature;
|
|
1415
|
+
try {
|
|
1416
|
+
signature = fs.readFileSync(filePath, { flag: 'r' }).subarray(0, 8);
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
return signature.length === 8 &&
|
|
1422
|
+
signature[0] === 0x89 &&
|
|
1423
|
+
signature[1] === 0x50 &&
|
|
1424
|
+
signature[2] === 0x4e &&
|
|
1425
|
+
signature[3] === 0x47 &&
|
|
1426
|
+
signature[4] === 0x0d &&
|
|
1427
|
+
signature[5] === 0x0a &&
|
|
1428
|
+
signature[6] === 0x1a &&
|
|
1429
|
+
signature[7] === 0x0a;
|
|
1430
|
+
}
|
|
860
1431
|
/**
|
|
861
1432
|
* Reads a JSON artifact if it exists and contains an object.
|
|
862
1433
|
*
|
|
@@ -1045,6 +1616,46 @@ function findMilestoneEvent(scenario, milestoneId) {
|
|
|
1045
1616
|
}
|
|
1046
1617
|
return null;
|
|
1047
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Returns true when a milestone is explicitly optional.
|
|
1621
|
+
*
|
|
1622
|
+
* @param {Record<string, unknown>} scenario
|
|
1623
|
+
* @param {unknown} milestoneId
|
|
1624
|
+
* @returns {boolean}
|
|
1625
|
+
*/
|
|
1626
|
+
function isOptionalMilestone(scenario, milestoneId) {
|
|
1627
|
+
if (typeof milestoneId !== 'string' || !Array.isArray(scenario.milestones)) {
|
|
1628
|
+
return false;
|
|
1629
|
+
}
|
|
1630
|
+
for (const milestone of scenario.milestones) {
|
|
1631
|
+
if (!isRecord(milestone)) {
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (milestone.id === milestoneId) {
|
|
1635
|
+
return milestone.required === false;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Returns true when a milestone represents one-time scenario readiness rather than a repeated cycle edge.
|
|
1642
|
+
*
|
|
1643
|
+
* @param {Record<string, unknown>} scenario
|
|
1644
|
+
* @param {unknown} milestoneId
|
|
1645
|
+
* @param {string | null} milestoneEvent
|
|
1646
|
+
* @returns {boolean}
|
|
1647
|
+
*/
|
|
1648
|
+
function isReadinessMilestone(scenario, milestoneId, milestoneEvent) {
|
|
1649
|
+
const readyEvent = isRecord(scenario.truthEvents) && isRecord(scenario.truthEvents.ready)
|
|
1650
|
+
? scenario.truthEvents.ready.event
|
|
1651
|
+
: undefined;
|
|
1652
|
+
if (typeof readyEvent === 'string' && milestoneEvent === readyEvent) {
|
|
1653
|
+
return true;
|
|
1654
|
+
}
|
|
1655
|
+
const id = typeof milestoneId === 'string' ? milestoneId.toLowerCase() : '';
|
|
1656
|
+
const event = typeof milestoneEvent === 'string' ? milestoneEvent.toLowerCase() : '';
|
|
1657
|
+
return id.includes('ready') || event.includes('ready');
|
|
1658
|
+
}
|
|
1048
1659
|
/**
|
|
1049
1660
|
* Builds a milestone-id to event-name lookup for schema-era scenarios.
|
|
1050
1661
|
*
|
|
@@ -1100,6 +1711,16 @@ function resolveProfileMetricEvents(scenario) {
|
|
|
1100
1711
|
milestone: toEvent,
|
|
1101
1712
|
};
|
|
1102
1713
|
}
|
|
1714
|
+
if (fromEvent && toEvent && isReadinessMilestone(scenario, budget.fromMilestone, fromEvent)) {
|
|
1715
|
+
return {
|
|
1716
|
+
milestone: toEvent,
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
if (fromEvent && toEvent && isOptionalMilestone(scenario, budget.fromMilestone)) {
|
|
1720
|
+
return {
|
|
1721
|
+
milestone: toEvent,
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1103
1724
|
if (fromEvent && toEvent) {
|
|
1104
1725
|
return {
|
|
1105
1726
|
closeRequested: toEvent,
|
|
@@ -1112,7 +1733,59 @@ function resolveProfileMetricEvents(scenario) {
|
|
|
1112
1733
|
return null;
|
|
1113
1734
|
}
|
|
1114
1735
|
/**
|
|
1115
|
-
*
|
|
1736
|
+
* Resolves how many repeated completion milestone events prove one cycle body.
|
|
1737
|
+
*
|
|
1738
|
+
* @param {Record<string, unknown>} scenario
|
|
1739
|
+
* @param {Record<string, string> | null} metricEvents
|
|
1740
|
+
* @returns {number}
|
|
1741
|
+
*/
|
|
1742
|
+
function resolveMilestoneEventsPerIteration(scenario, metricEvents) {
|
|
1743
|
+
if (!metricEvents || typeof metricEvents.milestone !== 'string' || !Array.isArray(scenario.steps)) {
|
|
1744
|
+
return 1;
|
|
1745
|
+
}
|
|
1746
|
+
const milestoneEvents = buildMilestoneEventLookup(scenario);
|
|
1747
|
+
const matchingWaitStepIds = new Set();
|
|
1748
|
+
for (const step of scenario.steps) {
|
|
1749
|
+
if (!isRecord(step) || step.kind !== 'waitForMilestone' || typeof step.milestone !== 'string') {
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
const event = milestoneEvents[step.milestone] ?? step.milestone;
|
|
1753
|
+
if (event === metricEvents.milestone && typeof step.id === 'string') {
|
|
1754
|
+
matchingWaitStepIds.add(step.id);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
if (matchingWaitStepIds.size === 0) {
|
|
1758
|
+
return 1;
|
|
1759
|
+
}
|
|
1760
|
+
const bodyStepIds = isRecord(scenario.cycles) && Array.isArray(scenario.cycles.bodyStepIds)
|
|
1761
|
+
? new Set(scenario.cycles.bodyStepIds.filter((entry) => typeof entry === 'string'))
|
|
1762
|
+
: null;
|
|
1763
|
+
if (bodyStepIds && bodyStepIds.size > 0) {
|
|
1764
|
+
let count = 0;
|
|
1765
|
+
let bodyCommandPending = false;
|
|
1766
|
+
for (const step of scenario.steps) {
|
|
1767
|
+
if (!isRecord(step)) {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
if (typeof step.id === 'string' && bodyStepIds.has(step.id) && step.kind === 'command') {
|
|
1771
|
+
bodyCommandPending = true;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (bodyCommandPending && typeof step.id === 'string' && matchingWaitStepIds.has(step.id)) {
|
|
1775
|
+
count += 1;
|
|
1776
|
+
bodyCommandPending = false;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
if (bodyCommandPending && step.kind === 'command') {
|
|
1780
|
+
bodyCommandPending = false;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return count > 1 ? count : 1;
|
|
1784
|
+
}
|
|
1785
|
+
return matchingWaitStepIds.size > 1 ? matchingWaitStepIds.size : 1;
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Maps shorthand milestone budget fields to aggregate profile budget keys.
|
|
1116
1789
|
*
|
|
1117
1790
|
* @param {{budget: Record<string, unknown>, metric: string}} options
|
|
1118
1791
|
* @returns {string | null}
|
|
@@ -1144,11 +1817,24 @@ function resolveProfileBudgets(scenario) {
|
|
|
1144
1817
|
return null;
|
|
1145
1818
|
}
|
|
1146
1819
|
const pass = {};
|
|
1820
|
+
const intervals = [];
|
|
1147
1821
|
for (const budget of scenario.budgets) {
|
|
1148
1822
|
if (!isRecord(budget) || typeof budget.limit !== 'number') {
|
|
1149
1823
|
continue;
|
|
1150
1824
|
}
|
|
1151
1825
|
if (budget.metric === 'p95' || budget.metric === 'p50') {
|
|
1826
|
+
const fromEvent = findMilestoneEvent(scenario, budget.fromMilestone);
|
|
1827
|
+
const toEvent = findMilestoneEvent(scenario, budget.toMilestone);
|
|
1828
|
+
if (fromEvent && toEvent) {
|
|
1829
|
+
intervals.push({
|
|
1830
|
+
name: typeof budget.name === 'string' ? budget.name : `${String(budget.fromMilestone)} to ${String(budget.toMilestone)}`,
|
|
1831
|
+
metric: budget.metric,
|
|
1832
|
+
limit: budget.limit,
|
|
1833
|
+
fromEvent,
|
|
1834
|
+
toEvent,
|
|
1835
|
+
});
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1152
1838
|
const budgetKey = resolveProfileBudgetKey({ budget, metric: budget.metric });
|
|
1153
1839
|
if (budgetKey) {
|
|
1154
1840
|
pass[budgetKey] = budget.limit;
|
|
@@ -1161,10 +1847,11 @@ function resolveProfileBudgets(scenario) {
|
|
|
1161
1847
|
pass.timeouts = budget.limit;
|
|
1162
1848
|
}
|
|
1163
1849
|
}
|
|
1164
|
-
return Object.keys(pass).length > 0
|
|
1850
|
+
return Object.keys(pass).length > 0 || intervals.length > 0
|
|
1165
1851
|
? {
|
|
1166
1852
|
metric: 'milestone budget',
|
|
1167
1853
|
pass,
|
|
1854
|
+
...(intervals.length > 0 ? { intervals } : {}),
|
|
1168
1855
|
}
|
|
1169
1856
|
: null;
|
|
1170
1857
|
}
|
|
@@ -1287,6 +1974,7 @@ async function runProfileMobile(args, options) {
|
|
|
1287
1974
|
const scenarioHash = hashScenarioContract(profileScenario);
|
|
1288
1975
|
const expectedIterations = resolveExpectedIterations(profileScenario);
|
|
1289
1976
|
const profileMetricEvents = resolveProfileMetricEvents(profileScenario);
|
|
1977
|
+
const milestoneEventsPerIteration = resolveMilestoneEventsPerIteration(profileScenario, profileMetricEvents);
|
|
1290
1978
|
const profileBudgets = resolveProfileBudgets(profileScenario);
|
|
1291
1979
|
const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
|
|
1292
1980
|
const artifactRoot = resolveArtifactRoot({ args, config, configPath, platform: options.platform });
|
|
@@ -1342,15 +2030,69 @@ async function runProfileMobile(args, options) {
|
|
|
1342
2030
|
verdict,
|
|
1343
2031
|
};
|
|
1344
2032
|
}
|
|
1345
|
-
|
|
2033
|
+
let attachedEvidence;
|
|
2034
|
+
try {
|
|
2035
|
+
attachedEvidence = await resolveAttachedEvidence({ args, layout, providerInputs: providerExecution.inputs });
|
|
2036
|
+
}
|
|
2037
|
+
catch (error) {
|
|
2038
|
+
const providerInput = providerExecution.inputs.find((input) => error instanceof Error && error.message.includes(input.sourcePath));
|
|
2039
|
+
const health = buildProviderCommandFailureHealth({
|
|
2040
|
+
failures: [
|
|
2041
|
+
{
|
|
2042
|
+
commandId: 'provider-evidence',
|
|
2043
|
+
code: 'provider_evidence_invalid',
|
|
2044
|
+
exitCode: null,
|
|
2045
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2046
|
+
name: 'evidence_provider_output_valid',
|
|
2047
|
+
nextAction: 'Fix the provider output so it satisfies the ASL evidence contract, then rerun the profile.',
|
|
2048
|
+
nextActionCode: 'fix_provider_evidence_output',
|
|
2049
|
+
phase: 'afterCapture',
|
|
2050
|
+
providerId: providerInput?.providerId ?? 'unknown-provider',
|
|
2051
|
+
...(providerInput?.manifestPath ? { rawPath: providerInput.manifestPath } : {}),
|
|
2052
|
+
},
|
|
2053
|
+
],
|
|
2054
|
+
runId,
|
|
2055
|
+
scenario: profileScenario,
|
|
2056
|
+
});
|
|
2057
|
+
const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics: {} });
|
|
2058
|
+
const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
|
|
2059
|
+
await writeJsonArtifact({
|
|
2060
|
+
filePath: layout.health,
|
|
2061
|
+
value: health,
|
|
2062
|
+
schema: SCHEMAS.health,
|
|
2063
|
+
label: 'Health artifact',
|
|
2064
|
+
});
|
|
2065
|
+
await writeJsonArtifact({
|
|
2066
|
+
filePath: layout.verdict,
|
|
2067
|
+
value: verdict,
|
|
2068
|
+
schema: SCHEMAS.verdict,
|
|
2069
|
+
label: 'Verdict artifact',
|
|
2070
|
+
});
|
|
2071
|
+
await writeTextArtifact({
|
|
2072
|
+
filePath: layout.agentSummary,
|
|
2073
|
+
content: agentSummary,
|
|
2074
|
+
});
|
|
2075
|
+
return {
|
|
2076
|
+
runDir,
|
|
2077
|
+
health,
|
|
2078
|
+
verdict,
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
1346
2081
|
const eventLogText = eventLogPath ? await fsp.readFile(eventLogPath, 'utf8') : '';
|
|
2082
|
+
const evidenceFilterRunId = resolveEvidenceFilterRunId({
|
|
2083
|
+
args,
|
|
2084
|
+
eventLogText,
|
|
2085
|
+
profileSessionEntriesPath,
|
|
2086
|
+
runId,
|
|
2087
|
+
scenarioName,
|
|
2088
|
+
});
|
|
1347
2089
|
const events = extractProfileEvents(eventLogText, {
|
|
1348
2090
|
scenario: scenarioName,
|
|
1349
|
-
runId,
|
|
2091
|
+
runId: evidenceFilterRunId,
|
|
1350
2092
|
});
|
|
1351
2093
|
const logSessionEntries = extractProfileSessionEntries(eventLogText, {
|
|
1352
2094
|
scenario: scenarioName,
|
|
1353
|
-
runId,
|
|
2095
|
+
runId: evidenceFilterRunId,
|
|
1354
2096
|
});
|
|
1355
2097
|
const storedSessionEntries = profileSessionEntriesPath
|
|
1356
2098
|
? JSON.parse(await fsp.readFile(profileSessionEntriesPath, 'utf8'))
|
|
@@ -1364,7 +2106,7 @@ async function runProfileMobile(args, options) {
|
|
|
1364
2106
|
}
|
|
1365
2107
|
const record = entry;
|
|
1366
2108
|
return ((!('scenario' in record) || record.scenario === scenarioName) &&
|
|
1367
|
-
(!('runId' in record) || record.runId ===
|
|
2109
|
+
(!('runId' in record) || record.runId === evidenceFilterRunId));
|
|
1368
2110
|
})
|
|
1369
2111
|
: []),
|
|
1370
2112
|
];
|
|
@@ -1376,11 +2118,16 @@ async function runProfileMobile(args, options) {
|
|
|
1376
2118
|
expectedIterations,
|
|
1377
2119
|
budgets: profileBudgets,
|
|
1378
2120
|
cycleEventNames: profileMetricEvents,
|
|
2121
|
+
milestoneEventsPerIteration,
|
|
1379
2122
|
artifacts: {
|
|
1380
2123
|
captures: attachedEvidence.captures,
|
|
1381
2124
|
signals: attachedEvidence.signals,
|
|
1382
2125
|
},
|
|
1383
2126
|
});
|
|
2127
|
+
const eventLogRawPath = eventLogPath ? `raw/${path.basename(eventLogPath)}` : undefined;
|
|
2128
|
+
const eventLogIsProfileSessionEvidenceOnly = options.platform === 'ios' &&
|
|
2129
|
+
eventLogPath &&
|
|
2130
|
+
path.basename(eventLogPath) === 'ios-profile-events.log';
|
|
1384
2131
|
const manifestArtifacts = {
|
|
1385
2132
|
causalRun: 'causal-run.json',
|
|
1386
2133
|
budgetVerdict: 'budget-verdict.json',
|
|
@@ -1389,13 +2136,20 @@ async function runProfileMobile(args, options) {
|
|
|
1389
2136
|
summary: 'summary.md',
|
|
1390
2137
|
scenario: toPortablePathReference(scenarioPath),
|
|
1391
2138
|
raw: {
|
|
1392
|
-
|
|
1393
|
-
|
|
2139
|
+
...(eventLogRawPath && !eventLogIsProfileSessionEvidenceOnly
|
|
2140
|
+
? {
|
|
2141
|
+
interactionLog: eventLogRawPath,
|
|
2142
|
+
deviceLog: eventLogRawPath,
|
|
2143
|
+
}
|
|
2144
|
+
: {}),
|
|
2145
|
+
...(profileSessionEntriesPath
|
|
2146
|
+
? { profileSessionEntries: `raw/${path.basename(profileSessionEntriesPath)}` }
|
|
2147
|
+
: {}),
|
|
1394
2148
|
},
|
|
1395
2149
|
captures: {
|
|
1396
2150
|
screenshots: attachedEvidence.captures.screenshots,
|
|
1397
|
-
video: attachedEvidence.captures.video
|
|
1398
|
-
uiTree: attachedEvidence.captures.uiTree
|
|
2151
|
+
...(attachedEvidence.captures.video ? { video: attachedEvidence.captures.video } : {}),
|
|
2152
|
+
...(attachedEvidence.captures.uiTree ? { uiTree: attachedEvidence.captures.uiTree } : {}),
|
|
1399
2153
|
},
|
|
1400
2154
|
signals: {
|
|
1401
2155
|
js: attachedEvidence.signals.js,
|
|
@@ -1403,8 +2157,18 @@ async function runProfileMobile(args, options) {
|
|
|
1403
2157
|
network: attachedEvidence.signals.network,
|
|
1404
2158
|
},
|
|
1405
2159
|
evidenceAttachments: buildEvidenceAttachmentManifest(attachedEvidence.attachments),
|
|
2160
|
+
diagnostics: buildDiagnosticInventory({
|
|
2161
|
+
args,
|
|
2162
|
+
attachedEvidence,
|
|
2163
|
+
eventLogPath,
|
|
2164
|
+
platform: options.platform,
|
|
2165
|
+
profileSessionEntriesPath,
|
|
2166
|
+
runDir,
|
|
2167
|
+
scenario: profileScenario,
|
|
2168
|
+
}),
|
|
1406
2169
|
};
|
|
1407
2170
|
const appId = resolveAppId({ config, platform: options.platform });
|
|
2171
|
+
const commandTransport = resolveCommandTransport({ args, interactionDriver, options });
|
|
1408
2172
|
const provenanceCohort = buildProfileProvenanceCohort({
|
|
1409
2173
|
appId,
|
|
1410
2174
|
args,
|
|
@@ -1465,7 +2229,16 @@ async function runProfileMobile(args, options) {
|
|
|
1465
2229
|
runId,
|
|
1466
2230
|
budgetEvaluation: metrics.budgetEvaluation ?? null,
|
|
1467
2231
|
});
|
|
1468
|
-
const health = buildProfileHealth({
|
|
2232
|
+
const health = buildProfileHealth({
|
|
2233
|
+
scenario: profileScenario,
|
|
2234
|
+
runId,
|
|
2235
|
+
metrics,
|
|
2236
|
+
diagnostics: manifestArtifacts.diagnostics,
|
|
2237
|
+
profileEventCount: events.length,
|
|
2238
|
+
profileSessionEntryCount: sessionEntries.length,
|
|
2239
|
+
commandTransport,
|
|
2240
|
+
sessionEntries,
|
|
2241
|
+
});
|
|
1469
2242
|
const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics });
|
|
1470
2243
|
const agentSummary = buildAgentSummaryMarkdown({ health, verdict, manifest });
|
|
1471
2244
|
const summary = buildSummaryMarkdown({ manifest, metrics });
|