Repository navigation
Timeline 2.0 - #157
Open
passsy wants to merge 59 commits into
Open
Timeline 2.0#157passsy wants to merge 59 commits into
passsy wants to merge 59 commits into
Conversation
Add frame-based navigation, resizable capture and inspector panes, and searchable widget inspection while keeping reports usable through file URLs. Compress and deduplicate frame data so large reports stay responsive instead of exhausting the browser renderer.
The details pane put the event's metadata in a sidebar table, which cost the pane half its width to say six things, and left the source excerpt a narrow column beside it. The pane is one column now and the table is gone: - The heading is the event type, with the dot in the event's own colour that the timeline markers already use. A failure gets a red halo, so the reason the report is open is the first thing on screen. - Both clocks are one line under the event text. A widget test runs inside fakeAsync, so `timestamp` is simulated time that jumps by whatever a pump was given. That answers what the test asked for but not how long anything took, which is why TimelineEvent now also records wallTime and the report shows both, always. - Caller is gone. The source section names the same file and line. - Capture is gone from the table. The capture in the pane on the left opens full screen instead, dismissed by the backdrop, the close button, or Escape. Overlays are absolutely positioned over the same box as the image, so annotations stay aligned however it letterboxes. The source section shows the whole file rather than six lines either side of the caller. Any fixed window cuts off the part of the method that explains the event. Files are stored once per path and referenced by events, otherwise a report would carry the same file once per event. The pane scrolls the caller line to the middle on selection, since the file no longer starts at the interesting part. Reports open on an event instead of "Select an event": the failure when there was one, the first event otherwise. Failures are marked by the producer through a new isFailure flag rather than the report matching on the label text. Source locations open in VS Code as well as IntelliJ. Only one IDE runs a test, so jetBrainsLink becomes ideLink plus ideName, which also turns the hardcoded "IDEA" button label into data. The URLs move into ide_link.dart behind a function taking the environment and the working directory, which is what makes them testable at all -- there was no test for the IntelliJ link before, there are eleven for both now.
A captured tree runs to tens of thousands of nodes and each row is around seven DOM elements, so building all of them blocked the page: measured at 955 ms for 5461 nodes, and it grows with the tree. That is the freeze behind "Expand all", and behind typing in the search box, which expands every node on the way to a match. The Tree text tab felt instant next to it because it only ever renders 250 lines. The tree is now flattened into a list of rows and only the rows in the viewport are rendered, with a spacer above and below so the scrollbar still measures the whole tree. Every row is the same height, which is what makes the height of an unrendered row knowable. At 16381 nodes, fully expanded: 58 rows and 407 elements in the DOM, no long task at all, and a search matching 9829 nodes stays just as quiet. That removes the reason the tree was only partly expanded, so the "large tree" mode and its notice are gone and everything is expanded from the start. A chain of single-child widgets now stays at one indentation, the way Flutter's own inspector shows it. Widget trees are mostly such chains and indenting every one of them pushed the interesting rows off the right edge: an 18-deep test tree indents 6 steps instead of 18. Enter in the search box scrolls to the match itself rather than calling scrollIntoView on an element that may not be rendered, and scrolls sideways when the match sits past the right edge. The lightbox can toggle the overlays. It shares the capture pane's switch, so the annotations do not disagree between the two.
The pane holds the whole file, so it opened at line 1 with the assertion somewhere below the fold. It was meant to scroll already, but did so against whatever the DOM held at the time, which was wrong twice: - On load the assignment landed before the pane had been laid out, and setting scrollTop on something with nothing to scroll does nothing. Then hydration replaced the pane and took the position with it. - Switching events scrolled to the previously selected event's line, because rendering happens after the state change. It looked right only while both lines happened to sit inside the same clamped view. The caller line now carries its line number, so the scroll waits until the pane actually holds the line it was asked for, waits until there is something to scroll, and holds the position for three frames so hydration cannot undo it. After that it stops, leaving a reader who scrolls away where they left off.
The scroll landed a constant distance too far down: caller line 52 put line 76 at the top, line 57 put line 81 there. offsetTop is measured from the closest positioned ancestor, and the source pane is not positioned, so it carried the pane's own distance down the page into the target. Measuring the line and the pane with getBoundingClientRect and adding the current scrollTop makes the target relative to the pane whatever sits above it. Line 52 now puts line 48 at the top. Also stops centring the line. Four lines of lead-in leaves the caller near the top, where it is easier to find and where more of the code after it is on screen.
Carrying the event row across frames dropped you into the middle of the next frame's story, hiding the assertions that led there. Left/right now always select the frame's first event, which is what clicking a capture in the filmstrip already did.
Everything it showed exists elsewhere: the toolbar already carries the frame and event arrows, the summary bar already reads 'Frame 1 - Event 1 of 8', and the details panel already heads with the coloured event type. The inspector is now a single row, so the workbench gets that height back.
Only spot's own assertions reported their failure through addEvent. A plain expect, or anything else the test threw, never reached the timeline, so the report ended at the last thing that worked and never showed why the test failed. The failure now gets a frame of its own at the end, which is also what the HTML report opens on. Reading it back from the test is not possible: by teardown, test.errors holds only 'Test failed. See exception logs above.' with an empty stack trace, because flutter_test dumps the real exception to the console and reports that placeholder in its place. So FlutterError.onError is wrapped for the duration of the test, which is where the real exception and its stack still pass through. The stack in the event drops the framework frames. A failure inside Flutter is reached through a dozen frames of gesture arenas, fake async and the test runner, and every async gap repeats them; what is left is the path through the test and the app. The console still gets the full trace.
Up and down walk the rows on screen, left and right close and open the selected node. The tree claims the arrows on a click into it and gives them back on the next click anywhere else, marked by a ring around the list so the mode is visible. Home and End keep jumping between events either way. Selecting a node already needed the flattened rows, and scrolling to one recomputed them a second time, so both now go through _currentTreeRows().
Only _select scrolled the strip, and initState sets the selection directly, so a report that opened on the failure showed it selected while the strip still sat at frame 1 with the failure off the right edge. The scroll itself was also scheduled for the next microtask, which on the first load runs before the strip has been laid out, and hydration takes the position with it shortly after. It now waits for the strip to be scrollable and holds the position for a few frames, the same way the source pane does. Replaces scrollIntoView() with the least scroll that puts the event on screen, so a nearby event no longer drags the strip across, and does it for the frame lane too, which scrolls on its own when a frame holds more events than fit.
The capture was taken during spot's teardown, which runs after the test's own teardowns because it is registered first and teardowns run last in first out. A teardown that pumps a frame therefore drew over the failure before it was captured, and the report showed that as the failing frame. The tree is now snapshotted the moment FlutterError.onError sees the error. Snapshots are only valid for the frame they were taken in, so getting the same object back at teardown proves nothing has been drawn since and the capture really is the failing frame. When something has, the event goes without a capture rather than with a misleading one. The event's widget tree comes from that same snapshot for the same reason.
The timeline only ever numbered the frames it recorded something in, so a test that rendered 5000 frames and asserted 11 times reported 11 frames. That hid the one number that says what a test costs. SchedulerBinding keeps no frame counter and persistent frame callbacks cannot be removed, so one callback counts for the whole process and each timeline takes a delta off it. Frames now carry their real number, the summary shows the total, and the stretch between two recorded frames is drawn as a gap with the frames that went by and how long they took on the test clock and the wall clock. A gap holds nothing to select, so frame navigation walks recorded frames and steps over them. The three rows of the strip share one set of grid tracks, which is what keeps them lined up now that columns differ in width.
printHTML deletes and rewrites the report directory, so a run that reports keeps it current. A run that does not report left it untouched: fix the test, run it again, and the link from the failing run still opened the old report, showing the source as it was before the edit along with events and captures from a run that no longer existed. Nothing was stale in how a report is built, the report simply was not the one the last run produced. reportOnError on a passing test and the off mode now remove what an earlier run left behind, so the link is plainly dead instead of quietly wrong. The directory is derived in reportDirectory() now, shared by writing and deleting, which is also what the hot-restart link uses.
Three ways to get a widget through the same 900ms animation, and the frame count each one leaves behind: pumpAndSettle at its default 100ms step, at a 16ms step, and a single pump straight to the end.
The gap was a five line block as wide as a frame, which pushed the frames it sits between apart for information nobody reads at a glance. It is now 24px of hatching with the count running vertically, and the timings moved into the tooltip. The animation example takes its expectations from the duration now, so changing the duration does not mean rewriting numbers by hand.
The native title tooltip took about a second to appear and cannot be styled. This is the same information in a small card, shown after 300ms of hover so brushing past a gap does not flash it, and hidden again straight away. CSS only, so it works before the page hydrates. A gap always sits between two frame columns, so the card is wider than the gap without risking a clip at either end of the strip.
The ruler cell had room for one of each and showed the test clock and the frame number. Both numbers have a counterpart worth seeing next to them: the wall clock says what the step really cost, and the spot count says where the column sits in the strip while the Flutter count says how far the test had rendered by then. Clocks on the left, counts on the right, two rows. The ruler grew 13px, which pushed the event lane out of the panel, so the panel opens 14px taller.
The letter S in an orange box was a placeholder. The magnifier is drawn inline as a data URI so the report stays a single self-contained file, and it still reads at 16px. The event lane only had room for two rows, which hid most of what a busy frame recorded. The panel opens at 320px now, which is five rows, and it is still draggable from there.
The help cursor swapped the pointer for a question mark on the way past, which is a lot of movement for a card that appears on its own anyway.
All four arrows move between captures, skipping frames that have none: there is nothing to put on screen for those, and landing on an empty lightbox reads as the report having lost the image. The ends hold rather than wrap, the way frame navigation does. The lightbox now takes the whole keyboard while it is open, so the timeline no longer moves underneath it. The selection follows the capture instead, which is where the report is left when it closes. The caption gained the position, because stepping needs something to orient against and a clamp at either end is otherwise silent.
Left and right walk the captures, up and down stay in the frame and walk its events. Every event of a frame shares the one capture, so what changes is the overlay drawn over it, which is the point: which widget each assertion in that frame matched. The caption says which event of the frame is showing, and the actions bar names both pairs of keys, since they are the only way to reach any of this.
Space opens the selected frame's capture full screen, and closes it again so the key is not dead once the lightbox is up. Nothing happens on a frame that was never captured. The lightbox no longer explains its keys. The toolbar already lists the others and now lists this one too, which is where a reader looks for them anyway.
A big widget tree is rendered one page at a time so the DOM never holds thousands of lines. Pins that a page carries only its own lines and knows whether there is more in either direction.
The timeline HTML referenced screenshot and overlay files without anything checking they had been written. Reads the URLs back out of events.json and looks for the files on disk.
The source pane capped at max-height 360px and .details-content was a plain block, so nothing handed it the leftover height. Dragging the divider up made the inspector taller and left the file view the same size. The chain runs on definite heights now, and .source-panel keeps a 220px floor so a short inspector still shows a readable slice of the file.
Modal, Events and ExpandableBox were the timeline UI before the workbench replaced it, and have been unreachable since. ModalState also subscribed to window.onKeyDown in initState and never cancelled it.
The StyleRule chain from SnackBarState up through TimelineAppState and the server app into Document(styles:) existed to carry seventeen lines of snackbar CSS, and emitted a second <style> for them. Rules landed in whichever of the two stylesheets the last person to touch them happened to open. The snackbar rules and its keyframes move into timelineCSS, the three forwarders go, and with them the theme constants the deleted components were the last users of. The comment justifying raw CSS claimed css() could not express media queries or focus states. It can, and keyframes too. The real reason is size, so it says that now. Also cancels the snackbar's dismiss timer, which outlived the component and called setState on it.
Jaspr moved to package:universal_web itself and its JS interop guide says to use it for browser APIs in server and static mode. dart:html is deprecated since Dart 3.7 and cannot compile to Wasm. The hand written web_stubs.dart declared 'external dynamic window', so every DOM call in the report was dynamic, guarded by ignore comments and propped up with casts like (event.client.y as num). universal_web is package:web in the browser and stubs on the VM, which is the same conditional import done by a package that also brings the types along. It was already a transitive dependency through Jaspr. Casts between interop types are unchecked, so the places that read properties off an event target now test with isA before casting rather than trusting the cast to fail on the wrong thing. Window has no onMouseDown/Move/Up getters the way Element does, so those go through EventStreamProviders, which is what the Element getters are built on. Side effect: the compiled bundle drops from 304,021 to 232,654 bytes, because package:web is extension types where dart:html is a library. Renames TimelineServerApp and TimelineClientApp along the way. Jaspr exports ServerApp and ClientApp, the latter being its hydration entry point, so runApp(const ClientApp()) read as the framework call and was not, and render_timeline had to import jaspr/server.dart with 'hide ServerApp'.
The values are elapsed since the first event of the test, but a leading + reads as a delta from the column next to it. Nothing else in the strip is relative to its neighbour. The unit stays. These are durations, and the scale switches at one second, so bare numbers would put a 590 and a 1.59 side by side meaning 0.59 s and 1.59 s.
The gaps already reported their two clocks on hover, the frames between them did not. Same card, same measurement, so a frame and the gap beside it read as one sequence. The span runs from the last event of the previous frame to the first event of this one. When a gap sits in between it covers the gap too, and the note says so: the numbers are the step to the frame, not the paint of it. The card is anchored to the left edge of a capture rather than centred on it. Centred, the first frame's card reached past the left edge of the strip, and a scroll container cannot scroll to reveal that. The gap card stays centred, since a gap never sits first.
frameStepCost and gapBetween were the same computation, so a gap and the frame after it always showed identical numbers. The same milliseconds appeared twice, once as a stretch of dead frames and once as the cost of one frame. Nothing records where the unrecorded frames end and the next one begins, so that span cannot be split. It belongs to the gap, which already reports it. A frame after a gap now says it is timed with the gap instead of repeating it, and only a frame sitting directly after another reports a cost. The test asserting the two agreed was asserting the bug.
The engine reports nothing to a widget test: addTimingsCallback never fires, because the automated binding does not rasterise. But the framework brackets every frame and both ends are reachable. A transient callback runs in handleBeginFrame, before build, layout and paint; the persistent callback spot already installs runs in handleDrawFrame, after them. Between the two is the framework generating a frame, between one frame and the next is the test. So each frame now carries three figures of its own: what the framework spent generating it, what the test spent afterwards on assertions and captures, and how far a pump moved the simulated clock. Measured on the frame, not derived from the distance to the neighbouring one, so a frame reports the same whether or not unrecorded frames sit in front of it. A gap reports the first two summed over the frames it covers. The clock is not one of the three costs. fakeAsync holds it still during real work, so simulated time can only say what a pump asked for. Two things keep the transient callback from breaking tests. scheduleNewFrame false stops it requesting a frame, which would leave one permanently scheduled and stop pumpAndSettle settling. And it is dropped as soon as the scheduler goes idle, because flutter_test fails a test that leaves a transient callback behind and checks straight after the test body, long before any teardown. Under a live binding nothing at the end of a frame says whether another is coming, so frames are only measured under the automated one.
A capture taller than its filmstrip tile stretched the row it sat in, pushing the caption out of the tile and drawing the image over the event lane below it. The image sized the row because the row was auto and the image's percentage height fell back to its own aspect ratio. The filmstrip row is now definite and the thumbnail is out of flow, so `object-fit` has a box to fit into and the capture letterboxes, centred, whatever its shape. The capture pane had the same shape of bug hidden behind a magic number: the image was capped at `100vh` minus the header, the timeline and 160px, which ignored the pane's real height. On a short window it shrank the capture to a sliver; the number could go negative. The canvas now carries the capture's own ratio and fits itself to the pane, which also makes it exactly the image box the widget outline is positioned against.
A tile was a fixed 132x111 box whatever the test rendered into it, so anything but the default 4:3 surface sat in it letterboxed, with dark bars for the rest of the tile. The tile now takes the aspect ratio of the captures, which for most tests means it is exactly the capture. A test is free to resize the surface halfway through, so the strip takes the shape most of its captures have and the odd frame of another shape letterboxes in its tile. Nothing is cropped either way. Both ends are bounded, because a test can render at any size: a very wide capture must not squash the tile to a line, and a very tall one must not push the ruler and the event lane out of the panel. The upper bound is what the panel has left over, so dragging the panel taller grows the captures until they reach their own shape. The captures' size now travels on the event rather than inside its gzipped widget tree, which the strip would otherwise have to decompress for every frame to learn.
It is a label on the strip, not content. Dragging across the filmstrip picked up "188 frames" along the way.
The tile clipped its own overflow, which the hover card is. It never needed to: the filmstrip row is definite and the thumbnail is clipped where it sits, so nothing else reaches past the tile.
passsy
force-pushed
the
dart-3.8-jaspr-latest
branch
from
August 5, 2026 23:39
e44f771 to
b2ade3f
Compare
The timeline installed a persistent frame callback of its own to count frames, which is what FrameClock now does for the whole process. Two callbacks were counting the same frames. Only the cost measurement stays here: FrameClock says how many frames went by, the bracket around a frame says what each one cost to generate and how much time the test spent between them.
Conflicts: - `screenshot.dart` — main's per-frame raster cache replaces the direct `toImageSync`, and the branch's capture render object rides along. - `CHANGELOG.md` — main restructured 0.20.0 into sections. The branch's entries move into a Timeline section, and the `act.inspectTap()` entry main already carries is no longer listed twice. Main caches what a widget fills into `debugFillProperties`, and the branch's tree snapshot filled them again outside that cache, once per widget per snapshot. It reads through the cache now. Render objects are mutable, so they keep filling on every read. A switched off timeline also stopped dumping the widget tree for events it drops on the floor.
main moved in parallel on the same files: it swapped dart:html for universal_web, split the report writer into an io and a web half to keep dart:io out of spot.dart's import graph, and added browser tests that drive the report. - The report writer of this branch now lives in print_html_io.dart, behind main's conditional export. The web half gains a no-op deleteHtmlReport. - _onPostTest keeps main's font-change pump around the report rendering, and records the test failure event inside it, because its capture can load the annotation font too. - expandable_box.dart and modal.dart stay deleted. Their test goes with them. - The two browser tests are pointed at the new UI: the lightbox instead of the modal, the capture pane instead of thumbnails. The report declares no <base> at all now (Document(base: null)), so that is what they assert. - jaspr_test follows jaspr to 0.23.
unnecessary_underscores and use_null_aware_elements only fire from language version 3.7 and 3.8 on, and CI analyzes with --fatal-infos.
Raising the SDK floor to 3.8 switches dart format to the tall style, and CI runs it with --set-exit-if-changed over the whole package.
Flutter 3.47 reports prefer_if_elements_to_conditional_expressions for the three places the track picks between a frame and a gap, and CI analyzes with --fatal-infos. The compiled client is unchanged in behaviour and not regenerated.
flutter test --platform chrome compiles every test file, also the ones that only run on the VM, and it cannot resolve an import that leaves test/. The test imported tool/serve_timeline.dart, which failed the whole Chrome run at compile time. serveTimeline now lives in lib/src/timeline/html, the tool keeps the argument parsing, and the test is marked VM only because it binds a socket.
The printed link is 'file://' in front of a native path. On Windows that is file://D:\a\..., which Uri.parse reads as host 'D', and the test then looked for events.json on a network share.
On Flutter 3.32, the declared minimum, FilledButton.tonalIcon and OutlinedButton.icon build a private subclass. spot<FilledButton>() matches the exact type, so the demo could not find its own buttons there.
ide_link, source_excerpt and stale_report read files or start processes, and failed every test on Chrome, compiled to JavaScript and to Wasm alike. The VS Code link test also expected a POSIX path. The link holds the native absolute path, which starts with a drive letter on Windows.
The formatter of Dart 3.12 lays these out differently, and CI formats with the current stable.
A run that reports nothing deletes the previous report, and the report directory is named after the test alone. A passing test therefore deleted the failure report of a test with the same name in another file, moments after its link was printed. The report now records which test file wrote it, and only that file removes it again. Two such tests still overwrite each other's report when both fail.
The failure event kept the first error FlutterError.onError saw. A test that expects an error and takes it with takeException() carries on, and when it failed later the report showed the expected error, with the tree and capture of that earlier moment. flutter_test cannot be asked whether an exception is still pending, so when a second error arrives the pending one is taken and, if there was one, handed straight back. Nothing pending means the test took the first error, and the new one replaces it.
The browser tests rendered hand-built events that carry their widget tree in plain. The writer compresses the tree and stores it once per frame, which only the client app unpacks, and nothing covered that path end to end.
The entries sat under 0.20.0, which is released. Adds the breaking changes: the Dart 3.8 / Flutter 3.32 floor and the new TimelineEvent parameters.
The gap columns were rewritten as if elements without regenerating the bundle. Same behaviour, now built from the checked-in source again.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Screen-Capture-2026-08-06-01-05-41.mp4
The generated
index.htmlwas a vertical list of screenshots with the event details underneath. It is now a workbench: a filmstrip of frames along the top, and below it the capture, the widget tree, the source that triggered the event, and the raw payload.Reading a test
expectmessage and the caller line, and the report opens on it.flutter_testreplaces the exception with a placeholder before it reaches us, so the real one is caught throughFlutterError.onError. An error the test took withtakeException()is not mistaken for the failure. Its screenshot is only used when the failing frame is still on screen — a teardown that pumps discards it rather than showing something else under a failure heading.Frame accounting
A test's frame count is the metric this was missing: 30 frames drawn used to move the old counter by 1, because it only counted frames an event was recorded in.
renderedFrameCountnow counts every frame. The strip shows the frames it has information for and collapses the rest into a gap — a narrow column saying how many frames went by with nothing recorded. Arrow navigation steps over them.Hovering a frame gives three figures, all measured on the frame itself:
pump(duration)moved the simulated clockThe engine reports nothing to a widget test —
addTimingsCallbacknever fires, because the automated binding does not rasterise. Instead the frame is bracketed: a transient callback inhandleBeginFrame, before build/layout/paint, and the persistent callback spot already installs inhandleDrawFrame, after them.The clock is deliberately not one of the costs.
fakeAsyncholds it still during real work, so simulated time can only report what a pump asked for, never what anything took.Platform
dart formatto the tall style and turns on two lints, so about 80 files that have nothing to do with the timeline are reformatted. Those are the last two commits and can be skipped in review.tool/serve_timeline.dartserves a generated report over loopback, since the inspector only renders client-side.Worth a closer look
_startCountingRenderedFramesinlib/src/timeline/timeline.dartis the highest-risk change here, because it hooks the scheduler in every test that touches the timeline. Three constraints shape it, and each one was a failing test first:scheduleNewFrame: false— the usualscheduleFrameCallbackrequests a frame, which leaves one permanently scheduled and stopspumpAndSettleever settling.flutter_testfails a test that leaves one behind, and checks straight after the test body, long before any teardown.AutomatedTestWidgetsFlutterBinding. A live binding draws from a real vsync, so nothing at the end of a frame says whether another is coming, and the idle heuristic does not hold.Report directories. A run that writes no report now deletes the previous one, so a stale report cannot be mistaken for the current run's. Reports are keyed by test name, so two identically named tests in different files share a directory. A report records which test file wrote it and only that file deletes it again, so one of them passing no longer removes the other's failure report. Both failing still overwrite each other — spotted, not fixed.
lib/src/timeline/html/sources/script.js.g.dartis the compiled client, checked in as a 237 KB string and shipped to pub.dev. Regenerate withdart run tool/compile_js.dartafter touching anything underhtml/. It is notdart formatclean and should be left as the generator writes it.