groupLogsByStep function
List<Map<String, dynamic> >
groupLogsByStep({
- required List<
String> stepsOutline, - required List<
int> stepDurationsMs, - required List<
String> stepStartTimes, - required List<
Map< apiEvents,String, dynamic> > - required List<
String> rawConsoleLines, - List<
Map< storageSteps = const [],String, dynamic> > - List<
Map< secureStorageSteps = const [],String, dynamic> > - List<
Map< keychainSteps = const [],String, dynamic> > - List<
Map< screenshotFrames = const [],String, dynamic> >
Groups API events, console lines, and storage diffs by top-level step outline index.
Prefers explicit stepIndex on events / [step=N] on console lines.
Falls back to stepStartTimes + stepDurationsMs time windows when
stepIndex is absent (older artifacts).
Implementation
List<Map<String, dynamic>> groupLogsByStep({
required List<String> stepsOutline,
required List<int> stepDurationsMs,
required List<String> stepStartTimes,
required List<Map<String, dynamic>> apiEvents,
required List<String> rawConsoleLines,
List<Map<String, dynamic>> storageSteps = const [],
List<Map<String, dynamic>> secureStorageSteps = const [],
List<Map<String, dynamic>> keychainSteps = const [],
List<Map<String, dynamic>> screenshotFrames = const [],
}) {
final result = <Map<String, dynamic>>[];
if (stepsOutline.isEmpty) return result;
final indexes = stepOutlineTopLevelIndexes(stepsOutline).toList();
final topLevelCount = indexes.isEmpty
? 0
: indexes.map((e) => e.topLevelIndex).reduce((a, b) => a > b ? a : b) + 1;
final buckets = List.generate(
stepsOutline.length,
(index) => <String, dynamic>{
'stepText': stepsOutline[index],
'apiCalls': <Map<String, dynamic>>[],
'appLogs': <String>[],
'storageChanges': <Map<String, dynamic>>[],
'secureStorageChanges': <Map<String, dynamic>>[],
'keychainChanges': <Map<String, dynamic>>[],
'screenshots': <Map<String, dynamic>>[],
},
);
DateTime? windowStart(int topLevelIndex) {
if (topLevelIndex < 0 || topLevelIndex >= stepStartTimes.length) {
return null;
}
try {
return DateTime.parse(stepStartTimes[topLevelIndex]);
} catch (_) {
return null;
}
}
DateTime? windowEnd(int topLevelIndex, DateTime start) {
if (topLevelIndex < 0 || topLevelIndex >= stepDurationsMs.length) {
return null;
}
return start.add(Duration(milliseconds: stepDurationsMs[topLevelIndex]));
}
bool inWindow(DateTime t, int topLevelIndex) {
final start = windowStart(topLevelIndex);
if (start == null) return false;
final end = windowEnd(topLevelIndex, start);
if (end == null) return false;
final lo = start.subtract(const Duration(milliseconds: 50));
final hi = end.add(const Duration(milliseconds: 50));
return !t.isBefore(lo) && !t.isAfter(hi);
}
int? outlineIndexForTopLevel(int topLevelIndex) {
for (var i = 0; i < indexes.length; i++) {
if (!indexes[i].nested && indexes[i].topLevelIndex == topLevelIndex) {
return i;
}
}
return null;
}
int? resolveTopLevel(Map<String, dynamic> ev) {
final stepIndexRaw = ev['stepIndex'];
final stepIndex =
stepIndexRaw is int ? stepIndexRaw : int.tryParse('$stepIndexRaw');
if (stepIndex != null) return stepIndex;
final timestampStr = ev['timestamp']?.toString();
if (timestampStr == null) return null;
try {
final t = DateTime.parse(timestampStr);
for (var ti = 0; ti < topLevelCount; ti++) {
if (inWindow(t, ti)) return ti;
}
} catch (_) {}
return null;
}
for (final ev in apiEvents) {
final targetTopLevel = resolveTopLevel(ev);
if (targetTopLevel == null) continue;
final outlineIndex = outlineIndexForTopLevel(targetTopLevel);
if (outlineIndex == null) continue;
(buckets[outlineIndex]['apiCalls'] as List).add(ev);
}
for (final line in rawConsoleLines) {
final parsed = parseConsoleLogLine(line);
int? targetTopLevel = parsed.stepIndex;
if (targetTopLevel == null && parsed.timestamp != null) {
for (var ti = 0; ti < topLevelCount; ti++) {
if (inWindow(parsed.timestamp!, ti)) {
targetTopLevel = ti;
break;
}
}
}
if (targetTopLevel == null) continue;
final outlineIndex = outlineIndexForTopLevel(targetTopLevel);
if (outlineIndex == null) continue;
(buckets[outlineIndex]['appLogs'] as List<String>).add(line);
}
void addStorageSteps(
List<Map<String, dynamic>> steps,
String bucketName,
) {
for (final step in steps) {
final targetTopLevel = resolveTopLevel(step);
if (targetTopLevel == null) continue;
final outlineIndex = outlineIndexForTopLevel(targetTopLevel);
if (outlineIndex == null) continue;
final changes = step['changes'];
if (changes is! List) continue;
final bucket = buckets[outlineIndex][bucketName] as List;
for (final change in changes) {
if (change is Map) {
bucket.add(Map<String, dynamic>.from(change));
}
}
}
}
addStorageSteps(storageSteps, 'storageChanges');
addStorageSteps(secureStorageSteps, 'secureStorageChanges');
addStorageSteps(keychainSteps, 'keychainChanges');
for (final frame in screenshotFrames) {
final targetTopLevel = resolveTopLevel(frame);
if (targetTopLevel == null) continue;
final outlineIndex = outlineIndexForTopLevel(targetTopLevel);
if (outlineIndex == null) continue;
(buckets[outlineIndex]['screenshots'] as List).add(frame);
}
// Nested outline rows keep stepText only — payloads stay on the parent
// non-nested step (viewers expand/inherit when rendering Step Details).
for (var i = 0; i < indexes.length; i++) {
if (!indexes[i].nested) {
result.add(buckets[i]);
continue;
}
result.add({
'stepText': indexes[i].line,
'apiCalls': <Map<String, dynamic>>[],
'appLogs': <String>[],
'storageChanges': <Map<String, dynamic>>[],
'secureStorageChanges': <Map<String, dynamic>>[],
'keychainChanges': <Map<String, dynamic>>[],
'screenshots': <Map<String, dynamic>>[],
});
}
return result;
}