Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] improvement: use jaspr to render html page #76

Merged
merged 10 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ analyzer:
exclude:
- 'lib/src/checks/**.dart'
- 'test/checks/**.dart'
- '**/*.g.dart'

# You can customize the lint rules set to your own liking. A list of all rules
# can be found at https://dart-lang.github.io/linter/lints/options/options.html
Expand Down
353 changes: 103 additions & 250 deletions lib/src/timeline/html/print_html.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/material.dart' as flt;
import 'package:jaspr/server.dart';
import 'package:spot/src/screenshot/screenshot.dart';
import 'package:spot/src/timeline/html/script.js.dart';
import 'package:spot/src/timeline/html/styles.css.dart';
import 'package:spot/src/timeline/html/sources/script.js.g.dart';
import 'package:spot/src/timeline/html/sources/styles.css.dart';
import 'package:spot/src/timeline/html/web/app.dart';
import 'package:spot/src/timeline/html/web/timeline_event.dart' as x;
import 'package:spot/src/timeline/timeline.dart';
import 'package:stack_trace/stack_trace.dart';
//ignore: implementation_imports
Expand All @@ -14,7 +15,7 @@ import 'package:test_api/src/backend/invoker.dart';
/// Writes the timeline as an HTML file
extension HtmlTimelinePrinter on Timeline {
/// Prints the timeline as an HTML file.
void printHTML() {
Future<void> printHTML() async {
final spotTempDir = Directory.systemTemp.createTempSync();

String name = test.test.name;
Expand Down Expand Up @@ -52,254 +53,51 @@ extension HtmlTimelinePrinter on Timeline {
}();

final htmlFile = File('${spotTempDir.path}/$nameForHtml');
final content = _timelineAsHTML(timeLineEvents: events);
htmlFile.writeAsStringSync(content);
//ignore: avoid_print
print('View time line here: file://${htmlFile.path}');
}
}

/// Returns the events in the timeline as an HTML string.
String _timelineAsHTML({required List<TimelineEvent> timeLineEvents}) {
final htmlBuffer = StringBuffer();
final nameWithHierarchy = _testNameWithHierarchy();

htmlBuffer.writeln('<html>');
htmlBuffer.writeln('<head>');
htmlBuffer.writeln('<title>Timeline Events</title>');
htmlBuffer.writeln(
'<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">',
);

final String eventsForScript =
timeLineEvents.where((event) => event.screenshot != null).map((event) {
return '{'
'src: "file://${event.screenshot!.file.path}", '
'title: "${event.eventType.label}", '
'color: "${event.eventType.color?.value.toRadixString(16) ?? '000000'}" '
'}';
}).join(',\n ');

htmlBuffer.writeln('<script>');
final script = timelineJS
.replaceAll('{{events}}', eventsForScript)
.replaceAll('{testName}', Invoker.current!.liveTest.test.name);
htmlBuffer.write(script);
htmlBuffer.writeln('</script>');

htmlBuffer.writeln('<style>');
htmlBuffer.write(timelineCSS);
htmlBuffer.writeln('</style>');

htmlBuffer.writeln('</head>');
htmlBuffer.writeln('<body>');
htmlBuffer.writeln('<div class="header">');
htmlBuffer.writeln(
'<img src="https://user-images.githubusercontent.com/1096485/188243198-7abfc785-8ecd-40cb-bb28-5561610432a4.png" height="100px">',
);
htmlBuffer.writeln('<h1>Timeline</h1>');
htmlBuffer.writeln('</div>');

htmlBuffer.writeln('<div class = "horizontal-spacer"><h2>Info</h2></div>');

htmlBuffer.writeln('<p><strong>Test:</strong> $nameWithHierarchy</p>');
htmlBuffer.writeln(
'<button class="button-spot" onclick="copyTestCommandToClipboard()">Copy test command</button>',
);
htmlBuffer.writeln('<div id="snackbar"></div>');

if (timeLineEvents.isNotEmpty) {
htmlBuffer
.writeln('<div class = "horizontal-spacer"><h2>Events</h2></div>');
}

String? projectName() {
Directory? dir = Directory.current;
String? projectDir;

while (dir != null) {
final ideaDir = Directory('${dir.path}/.idea');
if (ideaDir.existsSync()) {
// Update to the current directory path
projectDir = dir.path;
break;
}

// Move up to the parent directory
final parentDir = dir.parent;
if (dir.path == parentDir.path) {
// Reached the root directory
break;
}
dir = parentDir;
}

if (projectDir == null) return null;
final name = projectDir.split('/').lastOrNull;
return name;
}

String? eventCaller(Frame? initiator, {String? line}) {
if (initiator == null) return null;

final memberPart = initiator.member != null ? '${initiator.member} ' : '';
final uriPart = initiator.uri;
final linePart = line ?? initiator.line?.toString() ?? '0';
final columnPart = initiator.column?.toString() ?? '0';

return '$memberPart$uriPart:$linePart:$columnPart';
}

String? jetBrainsURL(TimelineEvent event) {
final initiator = event.initiator;
if (initiator == null) return null;

final isIntelliJ = Platform.environment.values.any(
(value) => value.toLowerCase().contains('intellij'),
);
if (!isIntelliJ) return null;

final lineNumber = (initiator.line ?? 0) - 1;
final clampedLine = lineNumber >= 0 ? lineNumber.toString() : '0';

final caller = eventCaller(initiator, line: clampedLine);
final name = projectName();
if (caller == null || name == null) return null;

final path = caller.trim().split(name).last.trim();
if (path.isEmpty) return null;

final normalizedPath = path.startsWith('/') ? path.substring(1) : path;

return 'jetbrains://idea/navigate/reference?project=$name&path=$normalizedPath';
}

final eventBuffer = StringBuffer();

void writeScreenshot(TimelineEvent event) {
final index = timeLineEvents.indexOf(event);
final screenshot = event.screenshot != null
? '<img src="file://${event.screenshot!.file.path}" class="thumbnail" alt="Screenshot" onclick="openModal($index)">'
: '';
eventBuffer.writeln(screenshot);
}

void writeExpandableBox({required String title, required String content}) {
final splitted = content.split('\n');

if (splitted.length > 1) {
eventBuffer.writeln('<div class="content">');
eventBuffer.writeln('<p>');
eventBuffer.writeln(
'<strong>${_htmlEscape(title)}:</strong> ${_htmlEscape(splitted.first)} ',
);
eventBuffer
.writeln('<pre>${_htmlEscape(splitted.skip(1).join('\n'))}</pre> ');
eventBuffer.writeln('</p>');
eventBuffer.writeln('</div>');
} else {
eventBuffer.writeln('<p>');
eventBuffer.writeln(
'<strong>${_htmlEscape(title)}:</strong> ${_htmlEscape(content)} ',
);
eventBuffer.writeln('</p>');
try {
final content = await renderTimelineWithJaspr(this.events);
htmlFile.writeAsStringSync(content);
//ignore: avoid_print
print('View time line here: file://${htmlFile.path}');
} catch (e, st) {
//ignore: avoid_print
print('Error writing HTML file: $e $st');
}
}

void writeEventType(TimelineEvent event) {
writeExpandableBox(title: 'Event Type', content: event.eventType.label);
}

void writeDetails(TimelineEvent event) {
writeExpandableBox(title: 'Details', content: event.details);
}

void writeTimestamp(TimelineEvent event) {
writeExpandableBox(
title: 'Timestamp',
content: event.timestamp.toIso8601String(),
);
}

void writeCaller(TimelineEvent event) {
final caller = eventCaller(event.initiator) ?? 'N/A';
writeExpandableBox(title: 'Caller', content: caller);
}

void writeJetBrainsLink(TimelineEvent event) {
final jetBrainsLink = jetBrainsURL(event);
if (jetBrainsLink == null) return;
eventBuffer.writeln('<a href="$jetBrainsLink">');
eventBuffer.writeln(
'<button class="secondary-button secondary-button--animated">',
);
eventBuffer.writeln('<span class="secondary-button__text">IDEA</span>');
eventBuffer.writeln('<span class="secondary-button__icon">→</span>');
eventBuffer.writeln('</button>');
eventBuffer.writeln('</a>');
}

final events = () {
for (final event in timeLineEvents) {
eventBuffer.writeln(
'<div '
'class="event" '
'style="border: ${event.color == Colors.grey ? '1px' : '2px'} solid ${event.color.toHex()};" '
'>',
);
writeScreenshot(event);
eventBuffer.writeln('<div class="event-details">');
writeEventType(event);
writeDetails(event);
writeTimestamp(event);

eventBuffer.writeln('<div class="code-location">');
writeCaller(event);
writeJetBrainsLink(event);
eventBuffer.writeln('</div>');

eventBuffer.writeln('</div>');
eventBuffer.writeln('</div>');
}
return eventBuffer.toString();
}();

htmlBuffer.write('<section class="events">');
htmlBuffer.write(events);
htmlBuffer.write('</section>');

// footer
htmlBuffer.writeln(
'<div>Tell us how to improve the timeline at <a href="https://github.com/passsy/spot/issues">github.com/passsy/spot</a></div>',
);

htmlBuffer.writeln('<div id="myModal" class="modal">');
htmlBuffer
.writeln('<span class="close" onclick="closeModal()">&times;</span>');
htmlBuffer.writeln('<div class="modal-content">');
htmlBuffer.writeln('<img id="img01" alt="Screenshot of the Event"/>');
htmlBuffer.writeln('<div id="caption">');
htmlBuffer
.writeln('<a class="nav nav-left" onclick="showPrev()">&#10094;</a>');
htmlBuffer.writeln('<div id="captionText"></div>');
htmlBuffer
.writeln('<a class="nav nav-right" onclick="showNext()">&#10095;</a>');
htmlBuffer.writeln('</div>'); // close caption
htmlBuffer.writeln('</div>'); // close modal-content
htmlBuffer.writeln('</div>'); // close modal
htmlBuffer.writeln('</body>');
htmlBuffer.writeln('</html>');

return htmlBuffer.toString();
}

String _htmlEscape(String text) {
const htmlEscape = HtmlEscape(HtmlEscapeMode.element);
return htmlEscape.convert(text);
Future<String> renderTimelineWithJaspr(List<TimelineEvent> events) async {
// Turn off isolate rendering.
Jaspr.initializeApp(useIsolates: false);

final nameWithHierarchy = testNameWithHierarchy();

return await renderComponent(Document(
title: "Timeline Events",
head: [
link(href: "https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap", rel: "stylesheet"),
DomComponent(tag: 'script', child: raw(timelineJS)),
DomComponent(tag: 'style', child: raw(timelineCSS)),
],
body: App(
testName: Invoker.current!.liveTest.test.name,
testNameWithHierarchy: nameWithHierarchy,
timelineEvents: events
.map((e) => x.TimelineEvent(
eventType: e.eventType.label,
details: e.details,
timestamp: e.timestamp.toIso8601String(),
screenshotUrl: e.screenshot != null ? 'file://${e.screenshot!.file.path}' : null,
color: e.color == flt.Colors.grey ? null : e.color.value & 0xFFFFFF,
caller: eventCaller(e.initiator) ?? 'N/A',
jetBrainsLink: jetBrainsURL(e),
))
.toList(),
),
));
}

/// Returns the test name including the group hierarchy.
String _testNameWithHierarchy() {
String testNameWithHierarchy() {
final test = Invoker.current?.liveTest;
if (test == null) {
return 'No test found';
Expand Down Expand Up @@ -355,8 +153,63 @@ String _testNameWithHierarchy() {
}
}

extension on Color {
String toHex() {
return '#${value.toRadixString(16).padLeft(8, '0').substring(2)}';
String? projectName() {
Directory? dir = Directory.current;
String? projectDir;

while (dir != null) {
final ideaDir = Directory('${dir.path}/.idea');
if (ideaDir.existsSync()) {
// Update to the current directory path
projectDir = dir.path;
break;
}

// Move up to the parent directory
final parentDir = dir.parent;
if (dir.path == parentDir.path) {
// Reached the root directory
break;
}
dir = parentDir;
}

if (projectDir == null) return null;
final name = projectDir.split('/').lastOrNull;
return name;
}

String? eventCaller(Frame? initiator, {String? line}) {
if (initiator == null) return null;

final memberPart = initiator.member != null ? '${initiator.member} ' : '';
final uriPart = initiator.uri;
final linePart = line ?? initiator.line?.toString() ?? '0';
final columnPart = initiator.column?.toString() ?? '0';

return '$memberPart$uriPart:$linePart:$columnPart';
}

String? jetBrainsURL(TimelineEvent event) {
final initiator = event.initiator;
if (initiator == null) return null;

final isIntelliJ = Platform.environment.values.any(
(value) => value.toLowerCase().contains('intellij'),
);
if (!isIntelliJ) return null;

final lineNumber = (initiator.line ?? 0) - 1;
final clampedLine = lineNumber >= 0 ? lineNumber.toString() : '0';

final caller = eventCaller(initiator, line: clampedLine);
final name = projectName();
if (caller == null || name == null) return null;

final path = caller.trim().split(name).last.trim();
if (path.isEmpty) return null;

final normalizedPath = path.startsWith('/') ? path.substring(1) : path;

return 'jetbrains://idea/navigate/reference?project=$name&path=$normalizedPath';
}
Loading