Skip to content

chore(dev): add burst simulator Chrome extension#1730

Merged
oliverlaz merged 14 commits intomasterfrom
burst-extension
May 4, 2026
Merged

chore(dev): add burst simulator Chrome extension#1730
oliverlaz merged 14 commits intomasterfrom
burst-extension

Conversation

@oliverlaz
Copy link
Copy Markdown
Member

@oliverlaz oliverlaz commented May 1, 2026

Summary

Adds a Manifest V3 Chrome extension under dev/burst-simulator-extension/ for stress / perf testing the chat UI by dispatching synthetic message.new and reaction.new events at a Stream Chat Channel instance. Dev-only — not part of the npm package, no build step, load-unpacked.

  • Auto-binds the channel. Walks the React fiber tree on popup open and finds any prop whose value duck-types as a Channel (getClient + cid + id + type — including the conventional channel prop on <Channel channel={ch}>). When multiple exist (e.g. ChannelList previews + active <Channel>), picks the one whose owning fiber wraps the largest subtree. Apps that already expose the channel as window.streamChannel (canonical) or window.channel (alias) get honored as-is, skipping the walk.
  • Realistic event payloads. Generated messages mirror real live_stream_chat events (full member, restricted_visibility, reaction_groups, etc.); reactions use the emoji-<unicode codepoint> keying the modern UI expects (e.g. emoji-1f4af) and pre-apply latest_reactions / reaction_counts / reaction_scores / reaction_groups to event.message so channel_state.addReaction lands them correctly.
  • Real receive path. Each event is stringified, JSON.parse'd locally (mirroring connection.onmessage's health-check shortcut), then handed to client.handleEvent which parses again before dispatching. Note: this matches the pre-perf: parse WebSocket events once at the transport boundary #1729 prod path; with that PR landed on master, prod now does a single parse — flipping the simulator to single-parse is an obvious follow-up.
  • Live run UX. While a paced run is in flight, the popup polls in-page state every 300 ms and shows Running · 230 / 1000 · 76 eps · 3s. The Run button morphs into a red Stop (■); clicking it flips an in-page abort flag, the simulator resolves on its next tick with aborted: true, and the popup reports Stopped at N in Tms.
  • State survives popup close. The simulator preserves state (with phase: 'done') and lastResult after the run ends. Re-opening the popup mid-run re-attaches as a watcher; re-opening after a run finished surfaces the summary instead of going back to the channel-detection text.
  • Design-system aesthetic. Geist + Geist Mono, light theme, slate neutrals, blue-600 primary, semantic info / success / warning / error surfaces.

Test plan

  • chrome://extensions → Developer mode on → Load unpacked → select dev/burst-simulator-extension/; pin the extension
  • Open a page where a Stream Chat Channel is rendered; click the extension; status reads Ready · <cid> · auto-bound · context 'value.channel' on <…> (or similar)
  • Manual override: in DevTools console, run window.channel = window.streamChannel; delete window.streamChannel; then re-open the popup → status reads Ready · <cid> · bound to window.channel
  • Run defaults (count: 1000, rate per second: 75, reactions ratio: 0.25, user pool size: 10) and confirm:
    • Live status line ticks Running · X / 1000 · ~75 eps · Ts during the run
    • ~750 messages + ~250 reactions land in the channel; reactions render with their emoji glyphs (not bare strings)
    • Same target receiving multiple reactions aggregates counts correctly
  • Click Stop mid-run; status reports Stopped at N in Tms
  • Close popup mid-run; re-open; popup re-attaches and continues live progress, then shows the final summary
  • Burst ceiling: count: 1000, rate per second: 0 while recording in DevTools Performance — confirm one long-task flame, channel state contains 1000 messages, no thrown errors
  • Error paths: open the popup on a tab with no React app; status reads No React root found on this page; click ↻ to re-detect

oliverlaz added 12 commits May 1, 2026 14:44
Manifest V3 popup-only extension that injects a synthetic event
generator into the page's main world via chrome.scripting and
dispatches message.new + reaction.new bursts at window.streamChannel
for stress/perf testing the chat UI.

Dev-only; not part of the npm package.
Stringify each generated event, JSON.parse it locally (mirroring
connection.onmessage's local parse), then hand the string to
client.handleEvent (which JSON.parses it a second time before
dispatching). Production does both parses per WS frame — the
simulator now does too, so the per-event CPU cost reflects what the
chat UI actually pays under load.

README updated to describe the ingestion path and to point at
channel.getClient().handleEvent as the simulator's entry point.
Detect window.streamChannel on popup open via main-world
executeScript and gate the Run button on success. The Run button is
disabled while detecting and stays disabled until a Channel-shaped
object (with getClient + handleEvent) is found on the active tab.

Reasons for failure are surfaced in the status line:
  - undefined: window.streamChannel is not set on this page
  - not-a-channel: set, but doesn't quack like Channel
  - no-handle-event: getClient().handleEvent missing (version skew)
  - getclient-threw: getClient() raised

A ↻ re-check button appears in the status block whenever a status
is settled (idle/ok/error), so users can retry without closing the
popup — useful when the channel mounts after popup open or after a
page reload while the popup is held open.
…iber walk

Replace the manual prerequisite with automatic detection: on popup
open, walk the React fiber tree on the active tab and find a
Channel-shaped object via duck-typing (getClient + cid + id + type).
Both prop-passed (<Channel channel={ch}>) and context-passed
(<Provider value={{ channel, ... }}>) channels are detected.

When multiple Channels exist (e.g. ChannelList previews + active
<Channel>), tiebreak by largest descendant subtree under the
owning fiber — the active channel wraps the message list while
previews are near-leaves.

A pre-existing window.streamChannel is honored as-is, so manual
overrides still work. Failure modes report distinct reasons:
no-react-root, no-channel-in-tree, preset-not-a-channel, etc.
Two reasons reactions weren't visible after a burst:

1. The reaction `type` used legacy short names ('love', 'haha', ...).
   Stream Chat reactions are keyed as `emoji-<unicode codepoint>`
   (e.g. 'emoji-1f4af' for 💯). Switched the EMOJIS table to
   { char, code } pairs and emit reaction.type = `emoji-${code}`.

2. channel_state.addReaction (src/channel_state.ts) replaces the
   in-state message with a formatMessage copy of event.message —
   so denormalized reaction state on event.message is what the UI
   ends up rendering. Generated messages had latest_reactions /
   reaction_counts / reaction_scores empty and no reaction_groups,
   so reactions were applied to the in-memory channel state but
   never reflected in the rendered message.

Now: every generated message carries reaction_groups: {}, and each
reaction.new event pre-applies the new reaction onto event.message
(latest_reactions, reaction_counts, reaction_scores,
reaction_groups) before dispatch — mirroring the real backend.
Mutating the message reference also means subsequent reactions on
the same target accumulate correctly across the burst.

Also adds top-level message_id to reaction.new events to match the
real event shape.
Reactions are now visible end-to-end in the UI; biasing the default
mix toward more reactions (50/50 vs the previous 30/70) makes the
common stress-test case more representative of a busy live chat.

Updates the popup form default, the simulator fallback, and the
README — all aligned.
The realistic stress-test target is closer to 1000 events than 200;
make that the popup default. Aligns popup form, simulator fallback,
and README.
- reactionRatio: 0.5 → 0.25 (1-in-4 reactions reads as a busy-but-
  message-dominated channel, closer to real traffic).
- ratePerSec: 50 → 75 (faster pacing keeps wall-clock duration
  closer to the prior count=200, rate=50 baseline now that count
  defaults to 1000 — ~13s instead of 20s).

Aligns popup form, simulator fallback, and README.
The "window" / reactToLastN field controlled an advanced detail
(clustering reactions on the most recent messages) that wasn't
worth a dedicated input. Removing it makes the popup simpler and
gives the more representative default — reactions are now picked
at random from any of the burst-generated messages, exercising the
full rendered tree rather than just its tail.

Drops the form field, popup.js config entry, simulator config +
sliding-window pop, and README bullet. Renames recentMessages →
messagesPool inside the simulator since "recent" no longer applies.
Surfaces the local JSON.parse "shadow" parse — currently always-on
to mirror today's prod path — as a popup toggle so it can be A/B'd
against an in-flight fix that removes the duplicate.

When on (default): JSON.parse(jsonString) runs locally and
client.handleEvent then parses again — both parses per dispatch,
matching production today.

When off: only client.handleEvent's parse runs — matches the
proposed single-parse pipeline.

The setting is also returned in the result object so successive
runs can be compared without ambiguity.
Visual rewrite to match the design-system foundations:
- Geist + Geist Mono replace Major Mono Display + JetBrains Mono
- Light theme on white with slate neutrals; drop the dark
  instrumentation aesthetic (hot coral accent, dotted texture,
  radial glows, animated indicator, accent strip)
- Blue-600 primary for the Run button and focus rings
- Status block reads idle (slate) / running (blue, pulsing) /
  ok (green) / error (red), each with soft tinted fills
- Type tokens map to the foundations table: heading-md (18/500)
  for the title, p-sm-medium (12/500) for field labels,
  p-sm-regular-uppercase for unit hints

Form refinements:
- Rename labels to be more descriptive: count -> total events,
  rate -> rate per second, reactions -> reactions ratio,
  users -> user pool size
- Remove the double parse toggle. The simulator still mirrors
  prod's two-per-frame parse cost — just no longer exposed as a
  popup knob.
…pup close

Two features in one cohesive change:

1. Live run readout + Stop button.
   While a paced run is in flight, the popup polls the simulator's
   in-page state every 300 ms and shows progress in the status line:
   "Running · 230 / 1000 · 76 eps · 3s". EPS is a 4-sample rolling
   average so it doesn't whiplash on the first tick. The Run button
   morphs into a red Stop (■); clicking it flips simulator.abort,
   the simulator breaks out at the next tick and returns a partial
   result with aborted: true. Burst-all mode skips polling and Stop
   since the entire batch lives in one rAF.

2. State preserved across popup close.
   The simulator stops nulling `state` at end-of-run — it now keeps
   the snapshot with phase='done' and stashes the full result on
   `simulateBurst.lastResult`. After channel detection the popup
   inspects page status; if a run is still in flight it re-attaches
   as a watcher (button -> Stop, polling resumes), and if the most
   recent run finished while the popup was closed it surfaces the
   summary instead of leaving the channel-detection text up.

   An isOwnRun flag distinguishes runs the current popup session
   started (its form-submit await handles the summary) from
   resumed/observed runs (the polling tick handles the
   done-transition).

A localTimer === pollTimer guard prevents an in-flight poll from
clobbering the final summary if polling stops mid-await.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Size Change: 0 B

Total Size: 380 kB

ℹ️ View Unchanged
Filename Size
dist/cjs/index.browser.js 127 kB
dist/cjs/index.node.js 128 kB
dist/esm/index.mjs 126 kB

compressed-size-action

oliverlaz added 2 commits May 1, 2026 15:50
Apps that already expose the channel as `window.channel` no longer
need extra plumbing — the popup and simulator both look for it as
a fallback when `window.streamChannel` isn't set.

Precedence: window.streamChannel (canonical) > window.channel
(alias) > React fiber walk. The popup's success status surfaces
which preset name was used: "Ready · cid · bound to window.channel".

The fiber walk has always picked up React props named `channel`
via duck-typing; just clarifying that in the README and updating
popup hint + recheck tooltip + preset error text accordingly.
Drops the "result is also logged to the page console" hint since
the popup now persists the summary across open/close.
Tighter wording — "StreamChannel is auto-detected from the React
component tree, or taken from window.channel or window.streamChannel."
@oliverlaz oliverlaz merged commit ea495b4 into master May 4, 2026
6 checks passed
@oliverlaz oliverlaz deleted the burst-extension branch May 4, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants