Testomat.io Chrome extension

Architecture — the map before your first change

Chrome MV3 side-panel extension for running Testomat.io manual runs next to the site under test. This file describes the code as it ships in this repository.

Companion docs — read across, they are not repeated here:

Doc What it is for
README.md + docs/guide/ The tester-facing docs: the landing page and a task-shaped guide with screenshots — what every button does and where the limits are. Read them once — it is the fastest way to learn the product.
docs/host-handoff.md The handoff.json contract for an app that launches the browser and signs the panel in.
PRIVACY.md What the extension collects, where it goes, and every off switch.

0. Ground rules that shape everything

  1. Zero build. No bundler, no npm dependency, no compile step. extension/ runs exactly as it is checked in. Third-party code is vendored as committed single files under extension/vendor/ (showdown.min.js, overtype.min.js).
  2. The module system is <script> tags. Every panel/editor file is a classic script: no ES modules, no namespacing. Each file wraps its innards in an IIFE and assigns one global (TestomatAPI, SiteTab, SiteAccess, CaptureAnnotate, HtmlSanitize, TestomatParams, PriorityIcons, Annotate, OfflineQueue), or — for the screen files — declares bare top-level functions into one shared scope. Load order is the dependency graph. See Rakes, §9.
  3. Single egress, no exceptions. At runtime the extension talks only to the configured Testomat instance. No CDN, no analytics, no other host. The one exception this file used to record — the opt-in AI step polish to api.anthropic.com — is gone: the polish that exists today (#23) asks the configured instance’s own /prompts endpoint, so the rule is absolute again. A change that would add a third-party host is not a normal change: it needs the maintainers’ agreement first.
  4. No invented endpoints. Every API call is verified against the product’s own source and curl-smoked before any UI code depends on it.

1. Module map

Four JavaScript realms hold the product — the worker, the panel document, the test page and the code injected into the tab under test. They share files but not memory: everything crossing a realm boundary goes through chrome.runtime messages or chrome.storage. Three more extension documents exist for one job each, and are listed under the map.

              ┌─────────────────────────── service worker ─────────────────────────────┐
              │ extension/background.js                                                │
 toolbar ───► │   action.onClicked   → openPreferredSurface (panel | own window)       │
  click       │   captureTab         → captureShot (captureVisibleTab | debugger)      │
              │   STEPREC_*          → step-recorder state in storage.session          │
              │   OPEN_FILE_OVERLAY  → content/file-overlay.js over the page           │
              │ importScripts: shared/  view-mode, site-tab, shot-store, step-rec-core,│
              │                         dbg-errors, fullpage-trim, presence-match      │
              │   evidence/buffer + recorder.js → EVIDENCE_* + the webRequest backbone │
              │   screenrec/parked + claim + session.js → SCREENREC_* + the cast attach│
              └───────────▲─────────────────────────────────▲──────────────────────────┘
                          │ runtime messages                │ executeScript / register
      ┌───────────────────┴───────────────┐   ┌─────────────┴────────────────────────┐
      │ side panel (sidepanel/index.html) │   │ injected into the tab under test     │
      │   core/    state, storage, views, │   │   content/step-recorder.js + its     │
      │            nav-model, gates, fit  │   │     content/rec-*.js helpers         │
      │   screens/ runs-list, run-view,   │   │   overlay/annotate-overlay.js        │
      │            test-view, tc-studio,  │   │     + shared/annotate-core.js        │
      │            evidence, attachments, │   │   evidence/relay.js      ISOLATED    │
      │            screen-rec, settings   │   │   evidence/page-hook.js      MAIN    │
      │   app.js   (loaded LAST)          │   │   content/rec-bar.js (screen rec)    │
      └───────────────────────────────────┘   │   content/review-overlay.js          │
                          │                   │   content/file-overlay.js            │
                          │                   └──────────────────────────────────────┘
                          │
                          │                   ┌──────────────────────────────────────┐
                          │                   │ test page (editor/editor.html)       │
                          │                   │   ?test= view | &edit | ?suite=      │
                          │                   │   ?annotate=<key> → annotate.js      │
                          │                   └──────────────────────────────────────┘
                          └──── shared/ (loaded by BOTH panel and editor) ───────────┘

The three pages the worker opens for one job each, holding no state of their own: offscreen/recorder.html (the screen recording’s MediaRecorder — an offscreen document, because a worker cannot hold a MediaStream), screenrec/review.html (preview + trim of a finished take, framed over the page by content/review-overlay.js) and viewer/viewer.html (the file a result’s tile opens, framed the same way by content/file-overlay.js).

1.1 Service worker — extension/background.js

Owns six unrelated things, because each of them needs a context that outlives the panel:

importScripts() at the head of the file pulls in twelve scripts: shared/view-mode.js, shared/site-tab.js, shared/shot-store.js, shared/step-rec-core.js, shared/dbg-errors.js, shared/fullpage-trim.js, shared/presence-match.js, evidence/buffer.js, evidence/recorder.js, screenrec/parked.js, screenrec/claim.js and screenrec/session.js. Two of them register listeners at load rather than waiting to be called: evidence/recorder.js its chrome.webRequest and chrome.runtime.onMessage ones, and screenrec/session.js its chrome.debugger, chrome.contextMenus, chrome.commands and chrome.tabs ones.

1.2 Side panel — extension/sidepanel/

index.html is the whole DOM: every view is a <section id="view-…"> toggled by show(). The 73 <script> tags at the foot of index.html (one vendored showdown.min.js, the rest ours) are the module system — plus shared/theme.js, the one script in the <head>. That list is the load order §2.2 and rake 1 are about.

Design tokens — extension/shared/tokens.css. One stylesheet, <link>ed before the page’s own CSS by both the panel and the editor, holding every colour, space, size, radius, shadow, depth and duration the extension uses. Three layers: a palette — the system’s two complete ramps, tailwind neutral and tailwind indigo (50 → 950 each), plus the product’s own notify/dark/kind hexes; indigo is the only blue the chrome may use, while the product’s semantic colours (run kinds, priority, status) and the annotator’s fixed red stay on their own hues on purpose —, scales (--space-* on tailwind’s 0.25rem base, a strict 4px grid with no half steps, plus --gutter for the page’s side inset, --text-*, --radius-*, --shadow-* named by role, --z-*, --dur-*), and the semantic layer components actually consume (--bg, --accent, --passed). Only the semantic layer changes between light and dark, and it says both schemes on ONE line: every semantic token is a light-dark(light, dark) pair resolved against the root’s color-scheme: light dark. That replaced a second, mirrored copy of the whole layer inside @media (prefers-color-scheme: dark) — which is how the two drifted, and why a new token routinely shipped light-only. It also means a page can PIN a scheme by setting color-scheme on <html>, which is the entire implementation of the styleguide’s OS / Light / Dark switch. Components ask for a token and nothing else — a raw value in style.css / editor.css is a bug or a missing token. This retired the editor’s hand-kept copy of the panel’s hexes (its header used to say “values are COPIED from sidepanel/style.css”).

Components — extension/shared/components.css. The layer above the tokens, <link>ed between tokens.css and each page’s own stylesheet: the controls that are identical on every surface — buttons, icon buttons, link buttons, inputs, selects, textareas. Markup composes an intent, a size and a shape: class="btn primary", class="input size-sm". Three sizes (--control-h-xs|sm|md = 24 / 28 / 32px, all spacing steps; md is the default AND the ceiling — the system has no taller control, so a primary action is made to stand out by its fill and .block, never by height), four intents (.primary / .secondary / .tertiary plus the .passed|.failed|.skipped|.neutral status family, with .danger, .solid and .outline as modifiers) and they compose freely — every intent exists at every size. Two decisions carry the file:

.segmented + .segment is the same vocabulary assembled rather than extended: one light --segment-track fill under the whole group, no dividers and no inset, a segment at rest the tertiary button and the chosen one the secondary button riding on it. Nothing pads the group, so the .size-* height IS the control’s height and the switch lines up with the button next to it. The one value it could not borrow is the chosen chip’s fill: on dark --card is the page under 5% white, the same step the track takes, so --segment-chip takes the next step of that white scale (on light it is the card itself). Everything else — border, lift, type, radius — is the button’s.

.checkbox / .radio / .switch + .choice are the CHOICE controls — the ones that answer a question with their own state instead of doing something, and the last hole in this file: they used to be accent-color and nothing else, which left the box itself to the operating system (a different shape, a different corner and a different blue from every control above them, and on dark it was Chrome’s idea of dark rather than this file’s). The native box is switched off (appearance: none) and all three are drawn from the tokens, shadcn’s geometry on this system’s ramp. They have ONE size each and no .size-* — the ladder just ends: 32 · 28 · 24 · 20 · 16, the switch’s track being the counter’s 20 laid on its side (36 wide, so a 16 knob travels exactly its own width) and the checkbox and radio its 16, which is also --control-icon, because a tick is an icon. Markup makes one decision, and it is about WHEN the answer takes effect: .checkbox is a value in a form that nothing acts on until it is submitted (the settings rows, committed by Save & validate) — and also an option a nearby button reads when it fires (Full page, under Attach screenshot: it writes itself on change, but what it reads as is a setting of the next shot). .switch is a MODE that changes the screen the moment it is flipped (Bulk, in the TC Studio quick bar). .radio is one of N and never fills — ring plus dot, so a column of radios cannot be misread as a column of checkboxes. The component is really the <label>: .choice is what is clicked, what is read out and what the hit area is, and it lines the control up with the FIRST line of a label that wraps, off a nudge computed from the type tokens rather than typed in. As with the fields, each rule is written twice — the class, and a :where() fallback on the bare element — so every checkbox already in the extension (a step tick, a rendered markdown task list) gets the skin without being found first.

Beyond the controls, the file holds every other piece both pages share: .badge (the passive pill — the status vocabulary again, so a badge and the button beside it agree), .card, .banner, .dialog, .toast, .tabs/.tab (underlined and folder-tab looks), .disclosure, .menu, .toolbar, .progress, .kv, .notice, .hint, .status-line, .empty, .kbd, .list-caption (the line above a list: the noun its rows are, the .counter saying how many are on screen, and a .caption-action pushed to the far end — indented by the row’s own padding so the word starts on the titles’ column), .tooltip, .bar (the page-chrome row that ran across both pages five times), .code, .markdown (rendered user prose, in the two readings both hosts had kept a copy of: an article by default — real headings on the 1.375/1.25/1.125em prose scale, what a body being written wants — or .markdown.sections, where a heading is a muted uppercase LABEL because the blob is embedded in a screen whose own title is the heading; without it a test’s ### Steps printed bigger and heavier than the title of the test it belongs to) and .spin (one keyframe where there were two identical ones). Each existed at least twice before — a panel copy and an editor copy of the toast, the guard dialog, the tab bar, the popup and the message shell; three hand-written dismiss buttons; two identical key/value grids; a dozen places that re-declared “muted, 12px” for a line of text.

Empty states — extension/shared/empty-state.js + the .empty component. The skeleton’s twin: that one draws content that has not arrived, this one content that is not there, and until it existed both were answered the same way — a muted 12px line (“No runs in this project yet.”, “No reported steps”) left alone in a container the size of the whole screen. Nine screens had written that line nine different ways, three of them with a hand-rolled title/hint/actions stack of their own.

The shape lives in the EMPTY section of components.css — a .empty-mark (an icon in a soft 48px box, one step past the control ceiling so it reads as an illustration and not as something to press), an .empty-title, an .empty-text capped at ~34ch, and .empty-actions, because a screen with no rows is a dead end unless something on it is clickable. Two shapes: the block for a whole view that came back empty, .compact for a nothing inside a screen that is otherwise full (an unopened folder, the errors-only log, a filtered menu). The mark is always muted — the accent belongs to the way out under the sentence, not to the picture of the thing that is missing.

EmptyState.build({icon, title, text, actions, compact, tag, className, id, live}) assembles it; tag: 'li' is what lets one be the single child of a <ul> without a <div> inside a list, and live sets role="status" on the states that took an aria-live status line’s job over (the filtered-empty runs list and run checklist now say it in the list itself, with a Clear search / Show all button under it, rather than in a line below the fold). Eleven of the panel’s call sites, plus one on the test page and one in shared/dropdown.js, and each names its own Material Symbol — search_off vs filter_alt_off vs find_in_page vs filter_list_off vs manage_search, folder_off vs create_new_folder — so two different nothings never look like the same nothing. Two of them — the runs list and the run checklist — pick the glyph at paint time, off whether it is a search or a filter that emptied the list; the runs list’s group row picks between a spinner and folder_off off whether it is still loading.

Tooltips — extension/shared/tooltip.js + the .tooltip component. The extension draws its own, and the browser’s title attribute is gone from every surface it reaches. The box is shadcn/ui’s tooltip in this system’s tokens (inverted surface — --tooltip-bg/--tooltip-fg, near-black on light and near-white on dark, exactly how shadcn’s own primary/primary-foreground pair resolves —, --radius-sm, 12px type, a rotated-square arrow, fade + zoom-in-95); the engine is one .tooltip node in the document, moved and rewritten per trigger. Markup asks with data-tip (plus optional data-tip-side="top|bottom|left|right", flipped when the side does not fit); JS asks with Tooltip.set(el, text) and clears with ''. The side is inherited from the nearest ancestor that names one, so a row states it once for everything it holds — both header bars do (#context-bar, .tc-bar: bottom), because a label above a header covers Back and the trail, and the fit test will not catch it on its own: a header a little down the page leaves just enough room above for a two-line tip to “fit” straight over the row. An ancestor that names the side is also the box the tip is measured off and left whole: these headers are two lines tall around a 32px Back button, so 8px under the button is still inside the row, and the label came out over the trail wearing the row’s own fill. It clears the block and stays centred on the control across the gap (the arrow with it), which is what still ties the two together.

Three things in it are load-bearing:

No title survives anywhere in the product. The annotator toolbar injected into the page was the last one — it lives in a shadow root, and from the document elementFromPoint answers with the shadow HOST, so the hit test could never see the button under the pointer. Tooltip.mount(root) moves the layer and the hit test INTO that root (and unmount() hands them back on teardown, because the injected world outlives the overlay); tooltip.js is injected with the core, and aria-describedby works because the label now lives in the same tree as its trigger. shared/annotate-core.js still chooses per realm — Tooltip present → data-tip, absent → title — but the absent branch is a last resort for an injection that lost a file, not a surface we ship. The assignee dropdown used to be the other one — an <option> inside a native select popup — until it became a custom listbox (test-view.js, same reasoning as the project switcher’s #126) so a row could carry a monogram and the popup a type-to-filter box; its tips now go through Tooltip like everything else. The vendored markdown toolbar writes title too; the editor moves those over with Tooltip.adopt() right after the mount rather than patching vendored code.

.list is the third component and works the same way one level up: it owns the row frame — the single bottom rule, the grey --row-hover wash, the light-blue --row-active selection (product house style: a list is rows on one surface, not a stack of bordered cards) — while the screen owns what a row contains. It retired the panel’s card rows and, with them, the four places that had to undo that card again for a nested, child, section or load-more row.

One thing a row contains is owned too, because every list in the panel had it: the TAIL. .row-count is the figure a row ends on — a suite’s test count, a rungroup’s run count, a run section’s done/total — plain muted text at the trailing edge rather than the boxed .counter the trees used to draw, which put a column of chips down the right of a tree and made a number that only qualifies its title read as a control. .counter stays for a number riding inside a control (a tab, a filter chip, a segment, a list caption), where the box is the host’s. .row-actions is the cell beside it, and .row-actions.on-hover is the one that matters: the hover actions are laid OVER the trailing edge, on their own copy of the row’s wash, and the count fades out under them — one slot at the end of a row, so crossing a list with the pointer never reflows a row.

Fields are declared twice in one selector list: .input / .select / .textarea at class specificity, and :where(input) / :where(select) / :where(textarea) at zero, so a plain field anywhere already looks right and any per-screen rule still beats it. .field wraps one when an ornament belongs inside its border — .field-icon (the search magnifier, pointer-transparent, a label rather than a control) and .field-clear (an .icon-btn parked in the right end). The three searches — runs, run and TC list — used to put that × in the flex row NEXT to the input, which read as a second control competing for the width; the wrapper made them one control again. This retired four hand-written copies of the same button (the panel’s button base, the editor’s .tc-btn, the annotator’s .annot-btn, the editor’s .tc-tool) and three of the same input. A control that only makes sense inside one section keeps its rules in that section’s stylesheet — but takes its height from a --control-h-* token.

Icons — extension/shared/icons.js. The extension has exactly ONE icon set: Material Symbols Rounded, weight 400, grade 0, fill 0, optical size 24 (fonts.google.com/icons). Path data is vendored verbatim from google/material-design-icons, file symbols/web/<name>/materialsymbolsrounded/<name>_24px.svg (Apache-2.0), and the keys of Icons.PATHS are the upstream names, so any icon in the panel can be traced back to the site by searching its key. This replaced the old @mdi/js paths — one set, one line weight. Three things follow from it:

What is NOT Material, and why: the type_*, status_* and tree_* blocks at the foot of PATHS are the design library’s own — the type-of-test squares, the run-status marks, and the folder/suite a tree row leads with — for things Material has no equivalent of. They are drawn on their own frames, so each names a viewBox in BOXES, and the folders are two-tone: a value there may be an array of [d, fill-opacity] layers instead of one path string, which is the only way a single-colour glyph can carry an outline over a lighter body. Beside them sits Icons.emoji(value, cls): Testomat lets a project replace a suite’s, a folder’s or a test’s icon with an emoji, and where it did, the panel draws that emoji in the same 20px square the glyph would have used (.tree-icon.emoji, .type-mark.emoji) — the mark the project chose, read off the API’s emoji field, not a glyph chosen here.

Deliberately NOT icons, and left as text: the keyboard names in the hotkey legend (⌘ ⏎ ↑ ↓ ← →) and the that ends a status line (“Screenshot attached ✓”) — the e2e reads that one.

The vendored markdown toolbar is drawn from this set too. OverType ships an SVG per button, which is a second icon set inside a product that has one — but the buttons are handed to it as data, so filteredToolbarButtons() maps each one onto a TOOLBAR_ICONS name (boldformat_bold, quoteformat_quote, …) and replaces its icon, on a copy: the vendored array is never mutated, name is untouched (it is what the toolbar writes as data-button and what the e2e reads back), and a button with no name in the map keeps its own glyph.

How a glyph reaches the markup. Icons.el() builds an <svg>, Icons.markup() the string form for the few innerHTML call sites, and Icons.hydrate() fills the static chrome: markup writes <span class="md-icon disc-caret" data-icon="chevron_right">, never a path. That placeholder is the rule — before it, index.html carried 23 inline <svg>s with the close path pasted six times, so fixing a glyph left stale copies behind. The one hand-drawn <svg> left in the markup is the connect hero’s illustration, which is art, not an icon. Priority glyphs go through shared/priority-icons.js, and the type-of-test squares through shared/test-type.js, both drawing from the same set.

Every name in Icons.BOXES draws on a frame of its own — the nine type_* marks on the library’s 13.3333-unit box (the Figma export verbatim, translated to that origin), the four status_* marks and the five md_* glyphs on a 16-unit one, the three tree_* marks on 20 — and Icons.boxOf(name) is what el()/markup() ask; a name absent from the map is on Material’s 960 box. Because the box is the drawing’s own, size means the drawing for these too: .type-mark asks for 12 and gets 12px of glyph in its 20px square.

Header layout. Three rows above <main>, in DOM order:

                       ── on a tab ROOT ──
#project-bar  | Project: Extension Demo ▾  ⟳  ↗  ⬜ |  every tab, hidden until known
#header-top   | Tests   Runs   Settings      ● Rec |  #tabbar + #rec-slot
#context-bar  |                                    |  hidden

                  ── immersed (non-root view) ──
#project-bar  |                                    |  folded away
#header-top   |                                    |  folded away
#context-bar  | ←  Runs / Manual tests…    ● Rec   |  the whole chrome
              |    Verify user can transfer funds  |

First-run connect screen. With nothing saved (!state.settings) the Settings view renders as a one-field connect screen — a hero (#connect-hero) plus the Connection block — and the header goes with it. It is the SAME form: same ids, same saveSettings(), so one place validates a token. applyConnectMode() (screens/settings.js) is called from show() on every view change and flips two presentational flags: data-mode="connect" on #view-settings and data-connect="true" on <body>. The CSS is a whitelist — every direct child of the section is hidden, then the few that belong are re-shown with an explicit order — so a settings row added later stays off the first screen unless someone puts it there. Keyed on state.settings, not isConfigured(): a saved config whose project failed to resolve gets the full form, where Disconnect / Forget instance / Sign out live.

Choose-a-project screen (pick). Step two of the first run: the token is saved, but the token’s project list has more than one entry, so nothing is scoped yet. askForProject() (core/project-switcher.js) — called from init() when initProjectSwitcher() answers 'choose', and from saveSettings() when a fresh token resolved no project — opens #view-pick (screens/project-pick.js): the search field, the list, and a footer holding the connected host and a tertiary Disconnect. It takes the panel whole, the way the connect screen before it does — body[data-view="pick"] folds away the project strip and the tab row, which are scoped by a project that does not exist yet — and it is a ROOT view, so there is no Back either; the footer is the way out. The rows are the header popup’s, built by the same projectRowEl() and filtered by the same matchProjects(), so the two surfaces cannot drift apart: only the row’s SKIN differs (.menu-option in the popup, the shared .list row on the screen). The arrows and Enter walk the list from the search field, which owns focus and carries aria-activedescendant. Picking a row is the ordinary switchProject(), whose “first pick” branch lands on a fresh runs view. This screen replaced opening that popup over an empty Settings page, which put the one thing there was to do on it inside a menu.

The full form has no token field. Connected, the Connection section is a card (#connection-card): the instance host with the connection verdict under it (#connection-state, the green Connected pill — Project not picked while a first run is still half-done), and DisconnectdisconnectInstance() (SettingsErase.disconnect()), which is forgetInstance() aimed at the host in state.settings whatever the Instance field in Advanced is showing, and which therefore ends on the connect screen. #set-token stays in the DOM (one form, one saveSettings()) but is display: none until syncTokenField() sets data-token="on" on the section — the one case being an Instance the panel holds no token for, i.e. a new self-hosted host being added. Each erase writes to the status line next to it: #connection-status, #settings-forget-status (inside Advanced) and #signout-status.

core/ — infrastructure every screen uses:

File Owns
core/state.js The single state object; $(); recordFor(); byRecordId(); isConfigured(); the capabilities.jwt gate and applyCapabilities(); the read-only lockout’s own probe (probeReadonly() / readonlyGate() / startReadonlyWatch()); the project-info and project-users caches and the resetProjectScopedState() that drops them; the projectEpoch / staleProject() guard that strands a container load a project switch has outrun; probeSession(); handleApiError().
core/storage.js loadStored() / persistSession() / migrateHostSettings() / dropAiApiKey() — the only writers of the session key.
core/nav-model.js NavModel — the navigation MODEL, no DOM and no globals: TAB_OF_VIEW, TABS, ROOT_VIEWS, TAB_ROOT, contextTitleFor(), webTarget()/webHref() (where a row’s ↗ points), and the two navigations as DESCRIPTORS rather than calls — nextViewForTab() for a tab click, backTargetFor() for Back. core/views.js is the half that paints what this decided.
core/views.js show(), switchTab/goBack (navStep() over NavModel’s descriptors), updateContextBar() (the contextual header row, its crumbs and its ↗), setImmersive(), refreshAll() (the project strip’s panel-wide Refresh — projects, the open view, both tab counts), and paintCounter() — the one writer of a .counter’s figure, which fades the number in when (and only when) it actually changed. Both filter rows and both tab chips go through it, and both rows are UPDATED rather than rebuilt, so a settling count moves nothing but its own digits. The toast, the status lines, the two gates and the two self-measuring rows stay here as bare delegates onto the four files below, so a screen still calls toast() and fitFilterChips() by name.
core/toast.js PanelToast — the bottom plaque and the inline status lines: show() / hide() / duration() and statusLine().
core/gates.js Gates — the two walls that take the panel away from the tester: applyReadonlyBlock() (the read-only lockout) and updateDegradedBanner() / dismissDegradedBanner() (the basic-mode strip).
core/fit.js Fit — the two rows that MEASURE themselves rather than take a breakpoint: filterChips() (overflow into a menu) and initActionLabels() (the create button that drops a word when the search field beside it runs out of room).
core/format.js Fmt.humanDuration() — one wording for a duration, whether it arrives in seconds off the run serializer or in milliseconds off a result. Reads no DOM, no API and no state.
core/status-icons.js StatusIcons — the status glyph map, the tree marks, the running ring and the run-kind badge, drawn from shared/icons.js.
core/suite-tree.js SuiteTree — the four pure decisions the Tests tab’s tree is made of: which nodes a search keeps, what a folder’s count says, which mark a node carries, which suites ride at the top.
core/dialog.js ConfirmDialog.ask() — the panel’s ONE confirm dialog. Core, not a screen: settings, attachments, the run lock and the offline queue all ask the same one.
core/write-status.js WriteCorewriteStatus() and writeEnvMeta(), the single status-write path all three surfaces reach (§3.2). Core rather than a screen because the offline queue’s replay is one of them.
core/session-restore.js SessionRestore — the guards a stored session is read back through (fromStored()) and the one-shot tcReturn breadcrumb (takeTcReturn()). Pure: the filter keys arrive as an argument.
core/open-run-intent.js OpenRunIntent — the web app’s Run in Extension click, left by the worker in storage.session and spent by whichever panel wakes up next.
core/project-switcher.js The header project strip: renderProjectBar(), renderProjectOpenLink() (the strip’s to <host>/projects/<slug>), refreshProjects() (JWT listProjects), switchProject() — which repoints settings.projectId, calls resetProjectScopedState() and lands the active tab on its root — and initProjectSwitcher() (boot paint + background refresh + resolving a config that has no project). The control is a custom listbox with a type-to-filter input (same pattern as the editor’s priority menu — a native <select> pops an OS-level menu over the narrow panel): initProjectDropdown() wires it from app init, renderProjectOptions() paints the filtered rows, and the popup’s z-index must stay in the root stacking context (the stacking-context rake). Two of its parts are shared with the choose-a-project screen rather than copied into it: matchProjects(rows, filter) (title AND slug) and projectRowEl(), the one project row — two lines plus the trailing count, and the dataset.projectId the e2e reads. askForProject() is what sends a connection with no project to that screen.
core/view-switch.js The header’s surface switch: initViewSwitch() asks ViewMode which surface this document is in, renderViewSwitch() names and marks it for the one the press would land on (“Open in window” / “Dock to side panel”), and the click opens the other one and closes this. The two directions are not symmetric: the window is the worker’s (VIEW_OPEN_WINDOW), while docking calls chrome.sidePanel.open() before its first await — the gesture lives only that long — on the normal-window id kept fresh from the worker’s focus tracking, because a popup cannot host a side panel.
core/env-info.js The Browser / OS / Viewport / URL facts, collected at click time and written as testrun meta (collectEnvMeta()). The Viewport is the tested tab’s own innerWidth × innerHeight in CSS pixels, read inside that tab rather than off the panel’s screen, and is omitted when the tab cannot be read. The URL is origin + pathname with a trailing (query trimmed) marker when a query/fragment was dropped, unless envFullUrl opts back in.
core/skeleton.js Skeleton — the loading placeholders. A navigation draws its own at once: the screen it left is already gone, so waiting out a clock only buys an empty view that fills 150 ms later, which reads as a flash of nothing rather than as speed; it fades in (.skeleton-enter). The boot is the one that still waits — paintBoot() starts a 250 ms clock (DELAY_MS) so a fast open lands on the real panel having drawn none, and fills #boot-skeleton with the whole panel (project strip, tab row, a runs list) while init walks token → projects → runs; bootDone() disarms it, and drops the container and the data-booting flag, on the first view that can be painted. show(view) mounts a per-view placeholder in front of the container it will replace and returns a HANDLE — hide(handle) removes it only while it is still the one in hand, so a stranded load settling late cannot clear the placeholder of the load that outran it. The run placeholder covers its whole screen — the summary card, the controls and the checklist — because a plan may also name blocks to hide while it is up, and those two paint empty (an empty bordered card over an empty chip row reads as a screen that failed rather than one loading); hide() gives them back on either path. A screen that already holds its rows in memory puts up no placeholder at all: it paints them and re-reads behind them (see 3.1). Every placeholder is composed from the real components with .skeleton bars in place of content (see the SKELETON section of shared/components.css) and from the bars in shared/skeleton.js, which is why there is no second copy of any row to keep in step.

What is RUNNING is a toast, not a line. Progress — Capturing tab…, Uploading <file>…, Saving passed…, Deleting <name>…, Finishing run…, Validating… — goes to the bottom plaque via progressToast(): a spinner, no auto-hide, one slot. An inline status line is where a screen prints its ANSWER, and it sits under the fold on a long screen, which is how a job that died left Capturing tab… standing under the status buttons forever. The plaque comes down on one rule rather than at every call site: setStatusLine() hides it — a screen printing its own line IS the answer — and a flow that ends printing nothing (the annotator’s Discard, a save whose tester moved on) calls hideToast() itself. The Loading … lines of the list screens stay lines: they pair with a skeleton, and a toast per navigation is noise, not information.

screens/ — the panel’s surfaces, all plain top-level functions or one IIFE global each. The list is longer than the number of screens: where one screen grew a subject of its own — a gate, a card, a bar — that subject took a file, so a change to it is not a change to the screen that happens to show it.

runs-list.js (dashboard + v2 modes, groups, filters, search, URL paste) with runs-paging.js (the paging arithmetic over state alone) and runs-url.js (what a pasted link may mean); run-view.js (the checklist, suite sections, inline statuses, the run session probe) with run-lock.js (runWriteLock() / recordWriteLock() / finishRun, §3.2b) and run-info.js (the Run info card); test-view.js (steps, example substitution, the priority icon and clickStatus) with test-gates.js (updateTestActionsState() — what the verdict buttons, the comment box, the step circles and the three attach controls look like when something refuses them), test-meta.js (custom status + assignee), test-summary.js (the reported-result card and the panel’s ONE file tile) and test-drafts.js (CommentDrafts, the unsent comment box); tc-studio.js (suite tree + TC list) with tc-quick-bar.js (Add new test, quick and bulk) and tc-suite-create.js (the inline new folder/suite row); evidence.js (recorder UI + the errors-only list) with evidence-format.js (the row line, the Attach snippet, the .txt) and evidence-upload.js (EvidenceUpload.log(), the upload on FAIL); screen-rec.js (the screen recording’s button and the upload of the parked take); attachments.js (the Attach file picker, its upload loop and the result’s attachment list); hotkeys.js (web-runner hotkeys + attachScreenshotAnnotated); livesync.js (20 s poll); offline-queue.js; project-pick.js (the choose-a-project screen); and settings.js with settings-form.js (what the form paints and reads back) and settings-erase.js (Forget instance / Disconnect / Sign out).

app.js is loaded last and is the only bootstrap: it wires every listener, restores settings + session, and picks the opening view.

1.3 Test page — extension/editor/

A standalone extension page (editor.html) serving four jobs, selected by query string (parseContext() in editor/editor.js, dispatched in boot()):

(?demo is a fifth entry, renderDemo() — a local, API-free round trip the e2e harness drives. It is not reachable from the product.)

Ten files behind those five entries: editor.js is the shell and the create/edit screen, view.js the read-only one, annotate.js the annotator surface above, and beside them draft.js (the editorDraft: keys and the dirty tracker), rec-session.js (the step recorder’s editor half, the Polish with AI switch and its polishSteps key), rec-format.js (the message that polish sends), md-sections.js (the ### Steps surgery both of those do), params-grid.js, priority-control.js and editor-icons.js (the icon names the other files share).

?ctx=panel means the page is navigated to in the side-panel document itself (the panel navigates away and back; sessionStorage.tcReturn is the breadcrumb that restores the TC list — written by openEditor() (screens/tc-studio.js) and spent by SessionRestore.takeTcReturn() at the panel’s next boot) and adds the ◀ Back button; ?ctx=tab is the same page in a full browser tab, without that chrome.

The trail (?ctx=panel). This page is the deepest step of the panel’s Tests path, so its title bar carries the same header the panel’s own drill-down does: buildCrumbs() puts Tests / ‹suite› over the title in a .tc-bar-main column beside Back — the shared .crumbs component, ancestors only. (In create mode the trail is the whole of that column: the title has a row of its own under the bar, see below.) The suite comes from tcReturn, read and not consumed (the panel’s boot still needs it). The suite crumb goes where Back goes; the Tests crumb drops tcReturn first so the panel lands on the tree instead of the suite. In create mode both go through requestBack(to), which is what keeps the unsaved-changes guard on every way out and sends the guard’s own Save & leave / Discard to whichever target opened it. In ctx=tab there is no panel to walk back into, so there is no trail.

The read-only view’s bar is that row to the pixel, because the tester walks straight from one into the other: 8/gutter padding, 4 across the row, no gap inside the column (the trail’s line box and the title’s already hold each other apart), a 2-line clamp on the title, and the same 52px height. It had drifted 4px in each direction, which stood the header 5px taller than the one it continues. The create bar keeps the shared .bar gap of 8 — the way back, the trail and the priority dropdown are separate controls.

The create editor’s shape. Top to bottom: the bar (Back, the trail, the priority dropdown), the title’s own full-width row, the Edit / Preview tabs, the writing tools, the markdown pane, and the footer. Four decisions are worth naming, because each one moved something out of the header:

This page also sets the panel’s body type (13px in a 1.5 line): it had never set one, so it ran on the browser’s 16px and every relative value in the rendered markdown was measured off a root this system does not have — a test’s ### Steps printed at 18px, above the title of the test it belongs to.

The editor’s colours. OverType’s own themes are somebody else’s palette — solar paints a heading orange on cream, cave violet on navy — so applyTheme() passes EDITOR_COLORS alongside the theme name and every colour that lands on the page is a token: var(--fg), var(--bg), var(--surface-2), and so on. OverType injects them as CSS custom properties into this document, so the whole set follows the OS light/dark switch through tokens.css by itself, which is why one map serves both theme names. The one design decision in there: a heading and the marks around it (###, **, the list bullet) are accent — indigo, the product’s own — with the marker mixed a step toward the page so the structure of the markdown reads without competing with the words.

Depth. The title bar is the shared --z-sticky (2), like every other pinned chrome row in the extension, and what makes that possible is .tc-body — the OverType host + preview pane — carrying isolation: isolate. The vendored editor stamps z-index: 100 !important on its toolbar (and 10000 on its dropdown and link tooltip), numbers written for a page that is nothing but OverType and unreachable from here (vendored code is never edited); an isolated pane orders them against each other and nothing else. The bar used to out-number them instead (--z-page-bar: 110), which put page chrome above the layers the whole extension shares — a tooltip opened from that bar (--z-tooltip 40) was painted under it, and so was the app frame (--z-frame 100). The priority menu still paints whole over the tabs and the toolbar, which is what that 110 had been for.

It reuses the panel’s globals via its own <script> list at the foot of editor.html — same TestomatAPI (the six api/*.js files plus api.js), same shared/ layer, same settings, same v2 endpoints. Where the two lists part: the editor alone loads OverType, shared/shot-store.js and the four annotator files (annot-geometry, annot-history, annot-keys, annotate-core); the panel alone loads params.js, shared/roving.js, shared/hovercard.js and shared/user-cell.js.

1.4 shared/ — loaded by more than one realm

File Loaded by Global
shared/view-mode.js worker (importScripts), panel ViewMode — which SURFACE the panel is in and which one the toolbar icon opens next: sidepanel (default) or window, remembered in chrome.storage.local.viewMode. Also owns the two window ids window mode needs (chrome.storage.session): the panel’s own popup, and the last focused NORMAL window the site under test is in
shared/site-tab.js worker (importScripts), panel, editor SiteTab, resolveSiteTab — the active site tab, its origin, and the BOUND target (§4.1)
shared/site-access.js panel, editor SiteAccess, ensureSiteAccess
shared/capture-annotate.js panel, editor CaptureAnnotate
shared/annotate-core.js + annot-geometry.js / annot-history.js / annot-keys.js editor page, and injected into the page Annotate core engine, and the three pure parts under it — AnnotGeometry (boxes, curves, hit tests, grips), AnnotHistory (the fifty-step undo stack) and AnnotKeys (what a key means). None of the three touches a canvas, a document or chrome.*, which is what lets them be read and tested on their own
shared/html-sanitize.js panel, editor sanitizeHtml — the extension’s only XSS boundary
shared/markdown.js panel, editor Md — the web runner’s exact pipeline in one place: escape → strip comments → showdown → sanitizeHtml. render(md) answers a DETACHED, already-sanitized <div>; nothing in the extension feeds showdown output to a document any other way
shared/img-hydrate.js panel, editor ImgHydrate — every image inside test CONTENT, shown despite a CSP that allows no remote <img>. hydrate(group, container) runs on a DETACHED rendered-markdown container, right after sanitizeHtml: each src is taken OFF the node before it can reach the document (a remote one the CSP blocks, a root-relative one the extension 404s — the blank box that was reported), the bytes are fetched through TestomatAPI.fetchAsset and handed back as a blob: URL, and a fetch that fails leaves an “open image ↗” link instead of nothing. load(group, url, img) is the same swap for a thumbnail the caller built (the reported-step screenshots and the result’s attachment list). Object URLs are owned per GROUP, revoked by release(group) when the container that painted them goes; held(group) is the e2e’s proof that they were
shared/icons.js panel, editor, and injected into the page Icons — the ONE icon set (Material Symbols Rounded, wght 400, fill 0)
shared/priority-icons.js panel, editor PriorityIcons (drawn from Icons) — mark(p) builds the .prio component a list row opens with
shared/test-type.js panel TestType — the type-of-test mark (.type-mark): of(record) reads the kind off a v2 record, mark(kind) draws the square a LIST row wears, mark(kind, {text:true}) the square-plus-word every other surface wears (the run header’s kind chip). Panel-only: the editor writes one test, it lists none
shared/user-cell.js panel UserCell — a PERSON, printed (.user-cell + .avatar): normalize(value) reads a name / an email / a record into {name,email,avatar}, cell(user) draws the monogram-plus-name Run info’s “Executed by”, “Created by” and “Assigned to” wear. The monogram is the floor and the photo is an upgrade: the CSP allows no remote <img>, so an avatar URL is fetched (cookieless, cached per URL) and swapped in as a blob:; anything that refuses — CORS, 404, a login — leaves the initials
shared/theme.js panel, editor — from <head>, the only script either page loads there Theme — the colour scheme: system (default) / light / dark. Applying one is a pin of color-scheme on <html>, which is what every token in tokens.css resolves its light-dark() pair against; system REMOVES the pin, so :root’s own color-scheme: light dark follows the OS live with no matchMedia listener. The <head> placement is the point: it runs before the first paint, so a pinned panel never flashes the OS scheme on the way in
shared/tooltip.js panel, editor Tooltip — the extension’s own tooltip, replacing the browser’s title
shared/hovercard.js panel HoverCard — the tooltip’s richer sibling: a card the pointer can ENTER, so it closes on a grace timer rather than on pointerleave
shared/empty-state.js panel, editor EmptyState — the one builder for every “there is nothing here” (drawn from Icons, so it loads after it)
shared/skeleton.js panel, editor Sk — the skeleton vocabulary: bar() (one grey bar) and lines() (a paragraph of unloaded prose). Which placeholder a screen puts up is the screen’s own business — core/skeleton.js for the panel, renderView({loading}) for the test page — but the bars are the same bars, and the two documents share no other script
shared/dropdown.js panel, editor Dropdown — this extension’s <select>: a <button> face plus the shared .menu, because a native select pops an OS-level menu over a 400px panel. Its host must not trap the popup in a stacking context of its own (the stacking-context rake)
shared/roving.js panel Roving — ONE tab stop for a whole list, not one per row: a run’s test rows carry three status buttons each, so a stop per row would turn a 200-test run into 800 of them. Tab enters the list once, the arrows walk it
shared/panel-link.js panel, editor PanelLink — the two long-lived ports every panel document dials: panel (a window holds the toolbar-icon surface) and panel-doc (a panel document is alive on any surface). The editor keeps the first and skips the second — it is no panel surface, but a toolbar click must not replace a half-written test
shared/handoff.js panel, editor, api.js, viewer page Handoff — the handoff.json contract in docs/host-handoff.md: a host app that launched this browser hands the panel a ready session instead of asking for a pasted token
shared/shot-store.js worker (importScripts), editor ShotStoreput/get/del/sweep over the testomat-shots IndexedDB database, where an unsaved draft’s staged screenshots live because storage.session’s ~10 MB cannot hold ten full-page JPEGs (§5.2)
shared/step-rec-core.js worker (importScripts) StepRecCore — the step recording’s ORDERING rules: where a line lands (srPlace), which twins a double-click drops (srPopTwins), when a line is final (srFinalEnd), and the settle constants. Pure over the stepRec record
shared/dbg-errors.js worker (importScripts) DbgErrors — which chrome.debugger / capture message is which failure (dbgIsForeignFrame, capNeedsGrant) and the copy the tester reads instead of Chrome’s. background.js and screenrec/session.js both read it
shared/fullpage-trim.js worker (importScripts) FullpageTrim — the full-page shot’s double-compose guard: overshoot() is the arithmetic, trimToDocument() the re-encode that acts on it (§4.3)
shared/presence-match.js worker (importScripts) PresenceMatch — which configured base URL earns a registered content/presence.js and which earns none
shared/webm-duration.js offscreen recorder page WebmDurationMediaRecorder streams its file, so the take carries no Duration and every player treats it as an endless stream; this patches the header before the file is parked
extension/api.js + api/errors.js, transport.js, paging.js, people.js, normalize.js, assets.js panel, editor TestomatAPI over ApiErrors / ApiTransport / ApiPaging / ApiPeople / ApiNormalize / ApiAssets — the six load first, in that order (§6)
params.js panel TestomatParams — substituting a parametrized row’s values into a test body

shared/site-tab.js is written to load in both a worker and a document — no document/window references — because importScripts and <script src> must both work.

1.5 Injected code (mostly not declared)

The only declared content_scripts entry is content/presence.js, the marker the web app reads to tell the extension is installed — statically on app.testomat.io, and registered at runtime (syncPresenceScript()) for the instance saved in Settings. The rest go in on demand through chrome.scripting.executeScript:

Those last two are the reason manifest.json has a web_accessible_resources entry at all: viewer/viewer.html and screenrec/review.html are the only two files a page may load, and both are framed by an overlay this extension put there itself.


2. How the realms talk

2.1 Runtime messages

Everything is chrome.runtime.sendMessage with a type string, plus two long-lived ports dialled by shared/panel-link.js: panel (a window holds the toolbar-icon surface) and panel-doc (a panel document is alive on any surface — the worker stops an evidence recording ~2 s after the last one is gone).

Type From → To Purpose
captureTab {fullPage} panel / editor → worker Screenshot the active tab (captureShot()). Replies {ok, dataUrl, tabId} plus four diagnostics the image itself does not carry: viewportOnly (the full page was refused and a viewport shot stood in — rake 10), framesMoved (how many foreign frames had to come out for it), trimmed (a double compose was cut back) and heightClipped (the page was taller than FULLPAGE_MAX_HEIGHT, 16384). A failure replies {ok:false, error, needsGrant}.
VIEW_OPEN_WINDOW panel → worker Open the panel in a window of its own, or focus the one already open. Replies {ok, windowId}; the panel then remembers the choice and closes the surface it was pressed in.
EVIDENCE_TOGGLE {tabId, recordId} panel → worker Start/stop the console+network recorder. recordId is the testrun the session binds to (start only) — §3.4.
EVIDENCE_STOP {reason} panel → worker Stop a recording the tester did not click off — the panel sends it on leaving the bound testrun. Idempotent: not recording is {ok:true} and nothing else.
EVIDENCE_STATUS panel → worker Poll {recording, tabId, recordId, tabTitle, tabUrl, windowSec, entryCount} (evStatus()). Every reply in this family carries that same status, except EVIDENCE_EVENTS, which answers {off}.
EVIDENCE_LIST {errorsOnly} panel → worker Entries inside the window, optionally errors only.
EVIDENCE_SNAPSHOT panel → worker All entries in the window (used to build the .txt log).
EVIDENCE_WIPE panel → worker Sign out and Forget on the ACTIVE instance: cancel the pending mirror, stop the recording DROPPING its buffer, then remove evidenceMirror — in that order, awaited, so the panel’s clear() cannot be undone by a late mirror.
EVIDENCE_EVENTS {events} injected relay → worker One batch from the page hook: net / console / log / ready rows. Replies {off}true meaning “this document is not being recorded”, which is the hook’s only stop signal.
EVIDENCE_HOOK_ON / EVIDENCE_HOOK_OFF worker → injected relay (tabs.sendMessage) Un-mute / mute the page hook. The mute survives in a document that never navigates, so a NEW recording on the same tab has to un-mute it — a re-inject cannot (double-init guard).
EVIDENCE_STOPPED {reason} worker → panel (broadcast) The recording ended without the tester clicking Rec off: target_closed, left-testrun, panel-closed. Sent by evStopIfRecording() — the one stop-and-broadcast path — and the single source of the toast.
STEPREC_START editor → worker Begin recording on the active site tab.
STEPREC_ADD {entry} injected script → worker One recorded step/expected line: {kind, text, action?, name?, context?:{row,section,column}, ctx?, manual?}. text is the rendered line every consumer reads; the structured fields are additive and stored verbatim, field by field, by srEntry(). ctx (#23) is the action’s context packet{action, element, near, page, value?, after} — copied whole rather than field by field, and the only thing the editor’s AI polish reads. manual:true marks an expected the tester typed on the indicator, as opposed to an auto navigation one. entry.replaces (dblclick only) names the single-click text this action supersedes and is a wire instruction — it never lands in the recording. Handled through srSerial() — one chain, because the state is a read-modify-write.
STEPREC_STATUS injected pill → worker The indicator’s own poll: {recording, count, paused, manualPause, blind, tabId} (srStatus()), no entries. It doubles as the ORPHAN check — srOrphaned() runs first and ends a recording whose editor document is gone (srOwnerOpen() asks Chrome for the docIds srStart recorded). It is not a question the worker answers with a stop; it is a status read that happens to notice one.
STEPREC_PULL editor → worker The editor’s own poll: the status PLUS every entry that is final and not yet handed over (srPull() moves sent). One message per tick, so a recorded action lands in the open test as it happens.
STEPREC_FLUSH editor → worker Asks the recorded tab for the field the caret still sits in before a Stop drains. Deliberately NOT on srSerial’s chain — the ADD it waits for needs that slot.
STEPREC_TITLE {title} injected script → worker Real document.title after a navigation, to refine the last nav entry.
STEPREC_STOP_REQUEST injected script / editor → worker Stop recording, keep the entries.
STEPREC_CONTINUE editor / injected → worker Clear the cap pause and grant another cap’s worth.
STEPREC_PAUSE {on} injected script → worker The tester’s own Pause/Resume on the indicator. Sets manualPause — never the cap’s paused, so it grants no extra cap (srPause() in background.js; only srContinue() grants one).
STEPREC_STOP editor → worker Drain: return the entries and clear the state. Idempotent.
STEPREC_PEEK e2e only → worker Read raw entries mid-recording. Explicitly marked “no production sender” in background.js’s STEPREC_* handler.
OPEN_RUN {url} web app (content/presence.js’s page) → worker Run in Extension: the surface opens FIRST and synchronously — the click’s gesture dies at the first await — then the url is parked in storage.session openRunIntent for whichever panel wakes up (core/open-run-intent.js spends it, and drops one older than 60 s).
OPEN_FILE_OVERLAY {url, name, mime} panel → worker Open a result’s file OVER the page under test (openFileOverlay()): the file is parked in storage.session fileOverlay, content/file-overlay.js is injected, and a page no extension may script falls back to a tab on viewer/viewer.html. Replies {ok, overlay, tabId}.
SCREENREC_START {recordId} / SCREENREC_STOP / SCREENREC_PAUSE {on} panel → worker The screen recording (§3.6). Start replies {ok, tabId} or {ok:false, reason, parked?}.
SCREENREC_STATUS panel / injected bar → worker {recording, paused, ms, bytes, tabId, recordId, capMs} while a take runs, else {recording:false, capMs, file}file being the parked take waiting for its review or its attach. srecStatus() asks the offscreen document too, so a session whose document died is reported idle.
SCREENREC_TARGET {recordId} panel → worker Which testrun a recording started FROM THE PAGE (hotkey, context menu) should bind to. Parked in storage.session screenRecTarget.
SCREENREC_TAKE / SCREENREC_OPEN_REVIEW / SCREENREC_DONE {attached} panel / review page → worker Read the parked take, re-open its review, and drop it once it has been attached or discarded.
SCREENREC_REVIEWED / SCREENREC_TRIMMED {url, …} review page → worker The review approved the take as recorded, or cut it. Only then does the worker broadcast SCREENREC_EVENT {event:'file'} — nothing is attached before that.
SCREENREC_CLAIM / SCREENREC_UNCLAIM {by} panel → worker One panel document at a time owns the upload: the file event is a broadcast, and every open panel would otherwise upload the same take. Serialized in screenrec/claim.js; an upload that fails un-claims so the next Retry attach… — here or in another panel — can take it.
SCREENREC_REVIEW_KEY injected review overlay → worker The one-shot screenRecReviewKey, which is how a framed screenrec/review.html proves the extension framed it and the page under test did not.
SCREENREC_EVENT {event, …} worker → panel (broadcast) started / review / file / ended.
SCREENREC_OFF {cmd, …} worker → offscreen document (broadcast); the review page too, for the trim start / cast-start / frame / pause / stop / state / revoke from the worker, and trim-begin / trim-chunk / trim-swap from screenrec/review.js. Frames go down a dedicated screenrec-frames port instead when one is up: a broadcast would copy every JPEG, several a second, into every extension page.
SCREENREC_FILE {file} offscreen document → worker Pushed when a cap or a closed tab ended the recording, with no stop to detach the cast.

The evidence handler ignores anything outside its EVIDENCE_REQUESTS set so the two onMessage listeners in the worker plus the recorder’s do not fight over one message.

2.2 The <script>-tag global convention

A new panel module must:

  1. be a classic script with no imports/exports;
  2. expose exactly one IIFE global (const Foo = (() => { … })()) or bare top-level functions;
  3. be added to sidepanel/index.html before app.js and after every global it reads at load time;
  4. declare what it reads from other files in a /* global … */ comment (the convention nearly every existing file follows — five core/ files carry none).

core/state.js must precede everything touching state; app.js must stay last. Nothing enforces either — see Rakes.


3. Data flow of the six things that matter

3.1 Opening a run

openRunsView() takes the memory-first path whenever this project’s list is already loaded (state.dashItems in dashboard mode, state.lastRuns in v2): the rows are painted at once — no clearing, no “Loading runs…”, no placeholder — and refreshRuns() re-reads behind them. Only with nothing to show (first open, or a project switch having emptied them) does it put up the placeholder and go through loadRuns() (screens/runs-list.js), which tries TestomatAPI.fetchDashboardPage(1) (JWT). Success ⇒ state.listMode = 'dashboard', capabilities.jwt = true. Failure only when jwtAvailable() === false falls back to the v2 listRuns + listRunGroups pair (listMode = 'v2'); any other error is re-thrown, so a real outage is not silently mistaken for degradation.

Clicking a run → openRunView(runId, title) (screens/run-view.js):

  1. reset per-run nav state (runFilter, runSearch, expandedSuites) — for a DIFFERENT run only, which also empties #run-info and the status chips so the new run can never wear the last one’s fields under its own title. Re-opening the run already on screen with its records in memory (Back from a test, the panel-wide Refresh) tears nothing down at all: no placeholder, no cleared checklist, no hidden pills — the paint stays and the re-read lands in it; show('run');
  2. Promise.allSettled([getRun, listTestruns, getRunInfo?, listTestrunExamples?]) — the legs are independent on purpose: a failed meta fetch degrades the header to a cached title and a muted note, but the checklist still renders. Only a failed test-list leg throws. The JSON:API getRunInfo and listTestrunExamples (state.runExamples, the row chips of a parametrized run, #52) ride in the same batch whenever capabilities.jwt is already true, and are applied OVER the v2 base before the first paint — so the run paints once, instead of inserting Started / Duration / Executed by a paint later. Without a proven session yet (the first run of a panel session) the old two-phase paint stands;
  3. state.records = <sorted by id ASC> (v2 returns newest-first; run order is creation order);
  4. renderRunView()startLiveSync()OfflineQueue.replay()probeRunSession(runId, { infoRead }) (fire-and-forget; loads run-replies, settles the Finish button, resolves assignee names — and skips its own refreshRunInfo() when the batch above already read it).

state.records are testrun records, keyed by record id — never test_id. A parametrized test has one record per example row and they all share test_id (recordFor() / byRecordId(), core/state.js).

3.2 Setting a status

Two entry points, one writer:

Both funnel into WriteCore.writeStatus(record, status, comment, onOptimistic, opts) (core/write-status.js). It is CORE and not a screen because the offline queue’s replay is the third caller, and it outlives every view:

syncBeginWrite()                    ← pause live-sync ticks
message = comment                   ← the tester's text, for EVERY status
Object.assign(record, {status, message})   ← optimistic mutation
onOptimistic()                      ← caller repaints
TestomatAPI.setStatus(...)          ← v2, token only — works in basic mode
  ↳ network / 401-403 «paused»?  →  OfflineQueue.enqueue(...)  → return {queued:true}
  ↳ otherwise                    →  throw, caller rolls back from its snapshot
Object.assign(record, saved, {test_id: record.test_id})
CommentDrafts.drop(record.id)       ← the comment reached the server; a QUEUED one does not
OfflineQueue.remove(record.id)      ← this status supersedes anything queued for the row
writeEnvMeta(record, status)        ← AFTER the id exists; JWT-only, never fatal
  collectEnvMeta()                  ← core/env-info.js   (Browser/OS/Viewport/URL)
  status === 'failed'
    ? EvidenceUpload.log(record)    ← screens/evidence-upload.js (uploads the .txt, returns its URL)
  TestomatAPI.setTestrunMeta(...)   ← one bulk_update POST for all the keys
syncEndWrite()                      ← resume ticks + force an immediate refetch

writeEnvMeta is also skipped for a locked result and for a REPLAY, which carries the environment snapshotted at enqueue instead of describing the tab the drain happens to find open (opts.envMeta).

setStatus POSTs on the first result and PUTs afterwards (api.js setStatus()). After a successful test-view write, step ticks for that record are dropped — and that is all: no status navigates. Marking used to auto-advance on pass/skip, which redirected the tester the moment the substatus / assignee / comment / attachment controls appear (they render only for a row with a real status). failed additionally opens Attachments & log.

Moving on is an explicit, always-available act: nextTest() (screens/test-view.js), wired to the persistent #btn-next-test button (app.js) and the bare N hotkey (hotkeys.js). It walks the VISIBLE sequence (orderedRecords() + rowVisible) to the next untested row, never re-opens the current test (it is reachable on an unmarked one), and has two dead ends — nothing untested anywhere → a Run complete toast + the run view; only the current test untested → This is the last untested test, stay put.

3.2a The reported-result summary

A test the run has ALREADY reported gets the web’s Summary panel above the marking controls (TestSummary.render()renderResultSummary(), screens/test-summary.js): the status + duration line, then four disclosures — Failure (titled Log when the status is not failed), Artifacts, Meta and Steps — and never a Stacktrace, which is the one web section the panel deliberately drops. All four hidden means the card hides too: a bare manual pass reaches that often.

It is a pure read of state.testrunDetail — the JSON:API detail probeSession() already prefetches on every test open — so it costs no extra request in the common case and is JWT-only like the priority icon. Attribute keys there are dasherized (run-time, in milliseconds). Four contract facts, all verified live against TestrunSerializer:

Rendering splits on attributes.automated, matching the web: a reporter message is printed verbatim (white-space: pre-wrap) because its newlines and indentation carry the assertion’s shape, while a manual message — everything the panel itself writes — goes through Md.render(). The tester’s own marking does NOT re-read it: status and message are the only two fields the panel can change, so TestSummary.refresh(record) patches them into the prefetched detail and repaints (refreshResultSummary()), which costs no request. Everything else on the card settles when the test is opened again.

The <img>s inside a manual message go through ImgHydrate before the body reaches the document, and the group is released on every repaint — the CSP allows no remote <img>, so an unhydrated one is a broken box.

3.2b Write locks — archived run, finished run, automated result

Three conditions make a result read-only in the panel, and all of them run through one piece of plumbing in screens/run-view.js:

runWriteLock()                  → '' | reason that holds for the WHOLE run
  runArchived()                 → 'Run is archived — results are read-only'
  runFinished()                 → 'Run is finished — results are read-only'
  runAutomated()                → 'Automated result — read-only in the panel'
recordWriteLock(record)         → runWriteLock() || (recordAutomated(record) ? … : '')

Precedence is deliberate: the run-level reason wins, because it is true of the row as well and every control has room for exactly one reason. Archived comes first because an archived run is usually finished too, and being told the wrong reason is the same as not being told — an honest reason is the point of the gate.

Every per-record write path calls RunLock.recordWriteLock(record) — the row ✓/✗/– buttons and writeRowStatus (screens/run-view.js), clickStatus and cycleStep (screens/test-view.js, hence both the test-view buttons and their hotkeys), onSubstatusChange (screens/test-meta.js), updateTestActionsState (screens/test-gates.js — the comment, the step checkboxes, the substatus select, the three attach controls), attachScreenshotAnnotated (screens/hotkeys.js), the file upload and the attachment delete (screens/attachments.js), the screen recording’s attach (screens/screen-rec.js) and the writeEnvMeta side effect (core/write-status.js). Eight files, one function. Only the run-wide surfaces read RunLock.runWriteLock() directly, and inside run-lock.js alone: the #run-lock-note paragraph therefore appears only when the reason holds for the whole view.

applyRunLock({force}) paints all of it from STATE rather than from a detected transition — several paths can learn the run finished, and a flip-detecting version missed the paint whenever a path other than the one holding the “before” value got there first (measured). It memoises a signature — the run-level reason plus the set of rows locked on their own account — so an unchanged poll tick costs nothing while a reporter result flipping one row of a mixed run (which need not change any status, so the row diff sees nothing) still repaints. The archived lock rides this unchanged: archiving changes no status either, but it does change the run-level reason, which the signature already carries.

The offline queue is the one write path that outlives the view, so it re-checks the lock itself rather than inheriting it — the target run is usually not the one on screen. dropLockedRunEntries() (screens/offline-queue.js) resolves each DISTINCT target run once and drops every entry aimed at a locked one, with the same three reasons in the same precedence: finished and automated off the v2 run detail (status / kind), archived off the JSON:API one, session-gated exactly as above. Automated is covered at RUN level only — a queued entry for one automated row of a mixed run still replays, because the drop resolves runs, not rows; the run-level kind is what the v2 run detail carries. A run it cannot read (offline — the common case here) is left alone and the replay fails on its own terms. Drops are always announced in one toast; a queued result is a tester’s unsent work and is never discarded silently.

Both reads per run, and all runs, go out under one Promise.all, because a drain’s dead time is not free: a trigger arriving inside it hits queueReplay()’s queueDraining re-entrancy guard, and serialising these two reads (~1.35x, 158 ms median to 117 ms) was enough to cost the finished-lock queue scenario a toast it had always received.

That guard no longer swallows the trigger — it used to return and raise nothing at all, so a tester’s Retry did nothing and an entry queued after the running pass took its snapshot waited for a trigger that might never come (a run view polls, but a panel on the runs list has no timer at all). The request is coalesced instead: remembered in queueRedrainRequested and honoured by ONE more drainPass() after the current one — one, not a loop, which is what keeps the original no-retry-storm promise. The banner Retry passes {user: true} and is the only trigger allowed to say so out loud (“Already syncing — your Retry runs right after”); a poll tick landing in the same window coalesces silently.

Assignee is deliberately outside all three locks; it is workflow metadata, tracked separately.

3.3 Attaching a screenshot

attachScreenshotAnnotated() (screens/hotkeys.js):

  1. RunLock.recordWriteLock(record), then resolveSiteTab({verb:'captured'}) (again inside CaptureAnnotate.ensureCapturePermission()). Not ok ⇒ the reason is toasted and the flow ends. Nothing prompts here — the only “no” is a restricted page.
  2. sendMessage({type:'captureTab', fullPage}) → the worker’s captureShot() → JPEG q80 data URL + the captured tabId. Two floors under it, because a capture that never answers used to leave the panel saying “Capturing tab…” with the button disabled and no way back: the panel races the round trip against CAPTURE_TIMEOUT_MS (30 s, captureTab() in screens/hotkeys.js), and the worker races captureVisibleTab against CAPTURE_VISIBLE_TIMEOUT_MS (8 s) — on an occluded or minimised window Chrome can leave that callback uncalled, and the timeout drops the flow to the debugger path instead of hanging.
  3. Two answers say the picture is not what was asked for, and each replaces the Annotating… plaque with its own sentence rather than stacking one on it: viewportOnly (the debugger was refused and the viewport stood in — rake 10) and heightClipped (the page is taller than FULLPAGE_MAX_HEIGHT).
  4. CaptureAnnotate.annotateImage(dataUrl, tabId, {toast}):
    • writes {dataUrl} to chrome.storage.session under a random annotate-<uuid> key;
    • primary: injects the overlay into that same tab, then WAITS for it to report on the same key: {ready:true} the moment its host is on the page, {error} for a bail (no stylesheet, no core, a throw in AnnotateCore). executeScript resolving only says the files ran; before this handshake an overlay that bailed after that left the panel on Annotating… for good, with the button disabled and nothing on screen. No report in 3 s and no overlay host on the page ⇒ the fallback below;
    • fallback: opens editor.html?annotate=<key> in a new tab and toasts why — the page cannot host the annotator, the overlay’s own {error}, or that it never came up;
    • the annotator writes back to the same key{resultDataUrl} for Save and for Keep original, {cancelled:true} for Discard — and the panel resolves off storage.session.onChanged. Closing the fallback tab with no write means Keep original.
  5. recordWriteLock() again: step 4 is interactive and the run can finish under it, so the click-time gate is minutes stale by now. A refusal ends the flow — and it keeps the annotated image in the pendingAnnotation slot and unhides #btn-save-annotation, which writes it to disk (anchor + object URL, no downloads permission). The refusal is unchanged; only the loss is. That button is deliberately outside every run lock: it writes to the tester’s own machine, never to the server.
  6. TestomatAPI.uploadAttachment(record.id, blob, …) — JSON:API multipart, JWT, scope testruns. Requires a result record, which is why the button is disabled until a status has been set.

Attaching local files

screens/attachments.js sits next to that button and reuses step 6 verbatim — a picked File is a Blob, so uploadAttachment takes it unchanged and there is no second upload contract.

The button clicks a hidden <input type="file" multiple>; the change handler snapshots the FileList and clears input.value immediately (so the same file can be picked twice, and a duplicate event is a no-op). Files upload one at a time: a failure toasts that file and the loop continues, and the status line ends N of M when some failed.

#attachment-list shows the result’s attachments — server truth from the attributes.attachments of the JSON:API testrun detail probeSession() already prefetched into state.testrunDetail, merged (de-duplicated by URL) with what this panel session uploaded onto that record. Screenshots and the auto-attached log therefore appear in it too — ONE grid for all three, drawn with the same fileTileItem() the Artifacts section uses (#21), so the panel has one file shape rather than a list here and a grid there. attTileItem() is that tile plus the one thing a manual upload has that a runner artifact does not: a bin, a sibling of the tile (.file-tile is itself a <button>, which cannot nest one) in its corner, revealed on hover or focus-within. It is on every tile and disabled with the reason in its tooltip when that file cannot go — no result record, a run lock, a degraded (no-JWT) session, or a row the server gave no id to (attId() reads one off an <instance>/attachments/<uid>.ext url when the response carried none). TestomatAPI.deleteAttachment() asks v2 first (DELETE /api/v2/{project}/attachments/{id}?testrun_id=…, the route the Testomat MCP server calls) and falls back to the Web-UI JSON:API on 403/404/405, the same split the upload lives with. The confirm dialog is interactive, so the lock is re-asked after it (#187); the row is dropped from both merge sources only once the server has said yes. The gate lives with the screenshot’s in updateTestActionsState(): no result record, or — uploads being JWT-only — a proven-degraded session.

An empty grid does not collapse and does not settle for a sentence: it is the one place the tester is already looking for where does the file go, so it draws a dashed dropzone spanning the grid. Clicking it opens the same picker the button does, a drop rides the same upload path, and it is gated on exactly the three reasons the Attach file button is (attUploadLock()) — a dropzone must never invite a drop the upload would then refuse. The gate is re-read at drop time, since a drag outlives the render that drew the zone, and updateTestActionsState() repaints the zone when the gate moves — but only while it is the zone, since rebuilding real tiles would drop their previews.

3.4 The evidence (console + network) recorder

Rebuilt with no chrome.debugger anywhere. Chrome refuses that attach as soon as any other extension has a frame in the page, which on a machine running Jam or 1Password is every page — so the recorder is now in-page instrumentation, the way Jam/LogRocket/Sentry do it.

Three parties: the worker (evidence/recorder.js) owns the buffer and the protocol; evidence/page-hook.js runs in the page’s own MAIN world; and evidence/relay.js (ISOLATED) is the only one of the two with chrome.runtime.

Scoped to one testrun. A recording belongs to the testrun it was started in — EVIDENCE_TOGGLE carries that recordId, the worker holds it with the session (mirror included) and reports it in every status. So the Rec chip lives on the test view only, and leaving that testrun by any road — Back to the run, another testrun, another view — or closing the last panel surface ends the session with a toast and no dialog. onViewShown() is the panel’s half (EVIDENCE_STOP {reason:'left-testrun'}, applied locally at once so the screen being left cannot keep a live chip); the panel-doc registry in background.js is the worker’s — every panel document dials that port, whatever surface hosts it, and the last one dying stops the recording ('panel-closed') after a ~2 s grace, so switching surfaces or reloading the panel, which close one document before opening the next, survive it. Both land on evStopIfRecording(), the same stop-and-broadcast path a closed recorded tab takes. With settings.evidenceAutoStart on (absent -> OFF) onViewShown() also starts a session on entering a testrun — evStartRecording(), the same path the Rec click takes, once per entry and silent in both outcomes, under exactly these binding rules, so a manual stop stands and leaving still ends it.

panel  EVIDENCE_TOGGLE {tabId, recordId}
  → evStart: arm the session, then
      executeScript relay (ISOLATED) + page-hook (MAIN) into the current document
      registerContentScripts(<origin>/*, document_start) for every later load
  → page-hook patches fetch + XMLHttpRequest and console.error/warn, and listens
      for error (capture phase — catches failed <img>/<script> loads too),
      unhandledrejection, securitypolicyviolation
    ... batched ~200 ms → window.postMessage → relay → EVIDENCE_EVENTS
  → chrome.webRequest (onBeforeRequest/onCompleted/onErrorOccurred/onBeforeRedirect)
      covers the rest of the tab: the document, subresources, sub-frames,
      workers, websocket handshakes, redirect chains, and anything before the
      hook lands
  → both feed the same ring buffer (evBuf.push() / evBuf.pushPageNet(), evidence/buffer.js)

The split is the design. evWrOwns() is the whole rule: webRequest drops type === 'xmlhttprequest' from frame 0 once the hook has said ready, and keeps everything else. So the two sources never describe the same request, and there is no heuristic de-duplication — except one deliberate merge (EvBuffer.adoptTwin) for the millisecond in which the hook exists but its ready has not arrived yet.

Why round that way: only the hook can read a response body, and only the hook sees a fetch to a third-party host under a per-origin grant. Only webRequest sees an <img> 404, the main document or a redirect.

Uncaught rows. An uncaught exception and an unhandled rejection never go through console.error, so they carry their own kind — exception, labelled uncaught.error in the list, the Attach snippet and the .txt — with source:line:col (the ErrorEvent for a throw, the reason’s own stack for a rejection) and the first STACK_LINES lines — six, the message included, so five frames — instead of the whole dump. A framework that logs an error and rethrows it would file two rows: the dedup rule is loggedAlready() in the hook — an uncaught row whose first line (minus the Uncaught / Unhandled promise rejection: prefix) matches a console.error from the last second is dropped, and the console row stands.

Bodies. For failures only (status ≥ 400 or a network error), capped at 16 KB, read off a res.clone() with the reader cancelled at the cap — a huge failed download is never pulled into memory. Request bodies are never read. With settings.evidenceCaptureBodies === false the hook does not read at all and flags the entry bodySkipped; the details pane and the .txt log then print “(body capture disabled)”. The flag reaches the page through the relay, and the hook parks any body read until that answer arrives, so an explicit OFF is never lost to a race. The relay reads the mirrored top-level evidenceCaptureBodies key: it runs in the tested page’s renderer, and the settings record it used to fetch also holds the API token.

Retention is the settings.evidenceWindowSec window (default 60 s; Settings refuses a save outside 10–600, and the recorder clamps what it is handed); the buffer is pruned to 2× the window and hard-capped at 1000 entries, mirrored to chrome.storage.session evidenceMirror every 2 s so a worker restart mid-recording recovers. That mirror is a COPY — the buffer itself lives in the worker — which is why erasing the storage area is not enough to get rid of it: EVIDENCE_WIPE cancels the pending evMirrorTimer, evStop(false)s the recording, and only then removes the key, so the removal is the last write by construction (evStop ends in evMirror(), which is awaited for exactly that ordering). Its callers are sign out and Forget on the active instance — §5.1. A plain Stop deliberately still keeps the last window: EVIDENCE_SNAPSHOT, the Attach button and the auto-attached .txt all read the buffer after the recording ends, so clearing it in evStop (L-4’s literal wording) would delete the feature. EvBuffer.windowEntries() (evidence/buffer.js) sorts by ts — the two sources arrive on different latencies, so append order is not time order. The ring buffer itself is that file: the caps, the prune, isError, and the adoptTwin merge window (MERGE_MS, 10 s). EVIDENCE_LIST {errorsOnly:true} is what the test view shows: console error/warning plus non-2xx or failed requests (isError in evidence/buffer.js, reached as evBuf.isError).

On FAIL with the recorder running, EvidenceUpload.log(record) (screens/evidence-upload.js) takes an EVIDENCE_SNAPSHOT, builds a readable .txt through EvidenceFormat.buildTxt() (screens/evidence-format.js, the escaping layer beside it) and uploads it as an attachment, returning the URL for the Console & network log meta key. It runs after the status write, so a row that earns its testrun id only in that response is covered too. It is its own file because it is the one thing here that LEAVES the browser on every fail, and core/write-status.js — core, not a screen — is what calls it.

Muting, not un-patching. A wrapper cannot be removed safely (other code may have wrapped fetch after us), so stopping a recording mutes the hook (EVIDENCE_HOOK_OFF), and a hook whose worker no longer knows it is muted by the {off:true} reply to its own batch. The mute survives in a document that never navigates — which is why starting a recording sends EVIDENCE_HOOK_ON before anything else.

What it costs. Browser-generated console rows beyond what the listeners recover are gone (deprecation notices), as are requests fired before the hook lands on a page with no document_start registration, page-worker traffic, WebSocket frames and foreign-frame traffic. Requests appear when they complete, not while pending. The user guide carries this list for testers.

3.5 The step recorder

Three parties: the editor page drives it, the worker owns the state, an injected content script produces the steps.

editor  STEPREC_START
  → srStart(): resolveSiteTab({verb:'recorded', activate:true}) → storage.session
      `stepRec` = { tabId, recording, paused, manualPause, capBonus, docIds,
        lastUrl, startedAt, blind, pendingOpen, entries: [], lastNavIdx, sent: 0 }
      `docIds` is the EDITOR document that owns this recording (srOwnerIds), so a
      closed panel ends it — the pill's own poll is what notices (srOrphaned)
  → srInjectSync(tab.id) → executeScript(icons + rec-*.js + step-recorder.js)

page    click/dblclick/type/select
  → the packet is armed AT EVENT TIME and the entry queued; ~400ms later (#23)
    `ctx.after` is read and the entry leaves — one outbox, in arrival order
  → STEPREC_ADD {entry:{kind, text, action, name, context, ctx, replaces?}}
  → srAdd → srPopTwins (a dblclick supersedes its own clicks)
          → srFlushOpen (the deferred `Open <url>` first step) → srPlace/srPush
      cap = 50 (+ capBonus), overridable by storage.session `stepRecCap`
      at the cap the recording PAUSES and drops the action

page    Pause/Resume on the indicator → STEPREC_PAUSE {on}
page    + Expected on the indicator   → STEPREC_ADD {kind:'expected', manual:true}

tabs.onUpdated on the recorded tab
  → changeInfo.url  ⇒ an `expected` entry: The "<title>" page opens
  → changeInfo.title ⇒ srRefineNav rewrites that entry once a REAL title lands
  → status 'complete' ⇒ re-inject (a full load killed the script)

editor  STEPREC_PULL (poll, 500ms) → the same status PLUS the entries that are
        final — the editor appends them to the open test right there
editor  STEPREC_STOP → returns only what the last pull had not reached and clears
        the state; Stop itself just ends the recording
        …then, with `Polish with AI` on, ONE POST /prompts over the whole
        recording — the raw steps are already in the body (#23)
page    STEPREC_STATUS (the content script's own poll) → status alone, no entries

Live insertion. Each recorded action lands in the open editor as it happens, so sent counts the entries already handed over and an entry may only be handed over once it can no longer change here. Exactly two things still rewrite the tail — a dblclick popping its own click twins (milliseconds) and srRefineNav() rewriting a navigation entry when the real title lands (up to a load) — so srFinalEnd() holds an entry for SR_SETTLE_MS (700), and a nav entry awaiting its title for SR_NAV_SETTLE_MS (3000), after which the URL-derived title stands. Past sent both rewriters give up (srPopTwins, srRefineNav): that line belongs to the editor now, and only rewriting our own copy would fork the two.

Polishing (#23, editor side — editor/rec-session.js). The Polish with AI switch (storage.local polishSteps, default off, hidden when jwtAvailable() === false) changes nothing about the insertion: every entry goes in raw, at once, exactly as it did before the feature existed. What the editor keeps alongside the body is the recording itself — recEntries (the entries, packets and all), recStart (the index its first item took in the ### Steps ordered list, counted BEFORE the insert), recCount, and recRawItems/recPolishedItems, the two texts each of its items has had.

finishRecording() drains the recorder, and then, with the switch on, sends the whole recording in ONE polishRecordedSteps() call (30s cap, setPolishTimeout in e2e) while the record button reads Polishing… and is disabled. The message is TEST: / PAGE: (from the first entry that has a packet) / EXISTING STEPS (items above recStart, omitted when there are none) / RECORDED ACTIONS — one block per recorded step: raw: (the sentence the recorder wrote), action:, element:, value:, near:, after:, note: (a manual expected that followed it; the worker’s auto nav line adds nothing, its change is already in after). An entry with no packet — the deferred Open <url> — is still an action: raw: + action: open + after: url=<the url>.

The answer’s numbered items replace the recording’s items 1:1 by index (MdSections.replaceItems()): fewer items leave the tail raw, extras are dropped, and an item whose current text is no longer what we last wrote there (recWritten()) was edited by hand and is skipped. Success toasts Steps polished ✓; a failure keeps the raw text and toasts once — a 422 in the server’s own words (error / details out of the JSON body), a 401/403 switches the feature off, hides it and persists that.

#tc-polish-btn beside the switch is the same button both ways: Undo polish right after a successful polish (back to recRawItems, 1:1, same skip rule), and Polish recorded steps whenever the draft holds a recording that is not polished — stopped with the switch off, the panel closed before Stop, or undone. Hidden while recording, with no recording, with the switch off, and after Save.

Stop and the button share one lane (runExclusive / recBusy), which is also what recStopping reports and what save() awaits (settleRec()) — Save must never send the raw text a moment before the answer rewrites it.

Two pauses, one dropped action. paused is the cap’s — STEPREC_CONTINUE clears it and grants another cap’s worth. manualPause is the tester’s Pause on the indicator, cleared only by Resume, so stepping out of the scenario never buys 50 more steps. srPush() (shared/step-rec-core.js) drops entries under either, and srAdd() returns before srFlushOpen() so a pause taken right after Start cannot swallow the deferred Open step. A navigation during a manual pause is followed (lastUrl) but not recorded.

The context packet (#23, content/rec-packet.js). Every action carries a ctx alongside its sentence: element (tag, role, type, own text, aria-label, title, placeholder, name, id, first 3 classes, icon), near (label, row, column, section, heading, the texts either side), page (title + origin+pathname, the env-meta trim), value for a type/select — the masked noun, never the secret — and after, read ~400ms later: url/title change, a toast, dialog or validation message ADDED in that window (one MutationObserver, armed at the action — a node classed alert/error/invalid-feedback/help-block/validation, or an aria-invalid control’s own message, reads as the dialog half), the control’s own state change, and a nearby badge that moved. Every field is best-effort inside a try/catch: a packet is never worth a lost step. Capped at ~1.5 KB of JSON (siblings and class go first). Two consequences elsewhere: the entry now leaves ~400ms late, so pagehide/beforeunload flush the outbox rather than let a navigating click die with the page, and srPlace() in the worker puts an action that lands right behind an auto-nav line back in front of it. The packet is built whether or not the AI switch is on — it is also what lets a nameless control be named by its row (elementName(el, fallback, near), content/rec-naming.js). The ~400ms is AFTER_MS and the cap is PACKET_MAX (1500 bytes), both in rec-packet.js; the queue that keeps arrival order across that window is content/rec-outbox.js.

What the injected script recognizes (content/step-recorder.js): buttons, links, summary, the button-ish inputs, and the ARIA custom controls ([role=checkbox|radio|switch|tab|menuitem|menuitemcheckbox|menuitemradio|option]) — each with its own wording. Two rules keep one action at one step: a role wrapper around a real input/select/textarea is skipped (the native change path owns it), and a dblclick sends the exact click text it supersedes so the worker can pop those trailing twins. The indicator’s own events are excluded — its shadow root is inside the page, so they compose out into document, and every recognizer (click, dblclick, blur, keydown, change) bails on fromIndicator(); without that, typing an expected result into the pill would record itself as a step.

What it refuses to record (content/rec-mask.js). A typed value is masked when the field’s type is password, its autocomplete is one of the card / one-time-code / password tokens (spec prefixes section-*/billing/shipping stripped first), the words it is named/labelled by hit PASSWORD_WORDS or SENSITIVE_WORDS, or the value is 13-19 digits passing Luhn (any \s, NBSP included, or - between groups) — that last one is the backstop, because the word list is best-effort by construction and a card can be typed into a field called anything. Two rules keep the list honest: every entry covers BOTH spellings words() can produce — it splits cardNumber/cc-num into card number/cc num, but a run-together lowercase cardnumber has no seam and arrives whole, which is what the optional space in card ?num(ber)? is for — and PASSWORD_WORDS exists because a revealed password is a type=text field (every show/hide eye button flips type). Its entries are whole words so passphrase matches it and passport does not — the latter is a government id, masked as “the value” by SENSITIVE_WORDS alongside ssn and tax id, and calling it a password would be the wrong word. isCardNumber() is deliberately narrower than the card entry: a Kanban Card title is masked, but only a bag carrying number/num/no/pan too is CALLED a card number. Masked steps read Type the card number|the password|the value into the <field> field and the field remembers a SENTINEL, not the secret, so lastTyped still collapses the Enter+blur pair without holding the value. Masking happens in the CONTENT SCRIPT, before send(), so the live insert never carries a value either. The Settings toggle stepRecNeverValues overrides all of it with Type text into the <field> fieldmaskedAllAs(), whose one exception is a password field, which keeps its noun because it is a certainty rather than a heuristic and no value is written either way (no other noun survives, or ON would still leak a hint at the content); it is read once per injection (plus a storage.onChanged listener for a mid-recording save) and a step that beats that read waits for it rather than assuming the default.

Element context (contextOf(), content/rec-naming.js). A name resolved from the control alone (“the checkbox”) is useless in a list, and the surroundings cannot be reconstructed later — so they are read at event time: the row/card (closest('tr, li, [role=row], [role=listitem]') → its first heading / header cell / cell / bold run, controls stripped), the section (a fieldset legend, else the nearest preceding heading over a bounded 8-ancestor × 12-sibling walk, never a full-DOM sweep) and the column (the matching th of a table with a real header row). The column also slots into the naming chain ahead of name/id — those are developer strings, a column header is what the tester reads. The step text takes exactly ONE clause, row before section (Check the Bulk checkbox in the "Bolt Cutters" row), and a clause that would only repeat the control’s own name is dropped.

+ Expected (content/rec-pill.js). An expectation is what the tester looked at, not a DOM event, so the pill carries an input for it (recording only — a paused recorder has no step to attach it to). It lives inside the shadow root, where no page CSS reaches it; its keystrokes are stopped on the way out of the shadow root so the page’s own hotkeys never fire; and while it is open it OWNS the pill, because the 500ms poll’s re-render would otherwise take the caret with it. Enter sends {kind:'expected', manual:true} (counted by srPush exactly like a step), Esc collapses. EVERY expected entry attaches to the step it followed as a - Expected: … sub-bullet — the shape screens/test-view.js folds into that step’s inline chip — the tester’s own and the automatic navigation ones alike (a flat list of the latter read as duplicates whenever a recording passed the same page title twice). That step is usually already in the body, so splitRecorded() takes the batch’s own steps first and falls back to leadSubs on the last item of ### Steps; the flat ### Expected section is left for what has no step to attach to at all — an expected recorded before the first step of the recording (splitRecorded(), editor/rec-format.js; the ### Steps surgery it drives is editor/md-sections.js).

Blind state. If executeScript throws while recording — that means the recorded tab moved to a page Chrome keeps extensions off — the recorder goes deaf and tabs.onUpdated stops carrying changeInfo.url, while the editor still shows a live recording. Rather than swallow that, srInjectSync() sets st.blind = true; blind rides along on STEPREC_STATUS so the editor’s existing poll can warn and name the fix (go back to the site under test). The recording is left running: the next inject that lands — tabs.onUpdated’s complete on the way back — revives it, and srCatchUpNav() then emits the one navigation entry that is true, the page open right now, rather than inventing the hops it missed.

Neither the step recorder nor the evidence recorder uses chrome.debugger, so they run in parallel with each other and with an open DevTools. The two debugger users left are the full-page screenshot and the screen recording’s fallback route — §3.6 and §7.

The ordering rules the worker applies to what arrives — srPlace, srPopTwins, srFlushOpen, srFinalEnd, srRefineNav and the settle constants — are shared/step-rec-core.js, pure over the stepRec record. background.js destructures them at the head of its step-recorder section, so the call sites still read as bare names.

3.6 The screen recording

screenrec/session.js in the worker, offscreen/recorder.html for the file, content/rec-bar.js for the controls, content/review-overlay.js + screenrec/review.html for the preview and trim, and screens/screen-rec.js in the panel, which owns the JWT and therefore the upload. Five pieces, one take.

Two capture routes, one recording. chrome.tabCapture.getMediaStreamId is the good one — no infobar, real frames — but Chrome hands the stream over only where the extension was INVOKED on that tab (activeTab; <all_urls> buys nothing here). Where that grant is missing srecStart() falls through to srecStartCast(): chrome.debugger attach → Page.startScreencast → a JPEG per frame, pumped to the offscreen canvas and acked, for as long as the recording lasts. That is the second debugger user of §7, and the long-lived one. It carries Chrome’s “…is debugging this browser” bar for the whole take, and that bar’s own Cancel is a chrome.debugger.onDetach the worker reads as a Stop that KEEPS the file, never a loss. The mode is written into the session record (mode: 'tab' | 'cast'), because every teardown path has to know whether there is an attach to give back.

Two things ride on the cast attach:

Nothing is attached until the tester says so. Every end of a recording funnels through srecFinish(): the cast is torn down, screenRec is cleared, and the bytes — when there are any, and no earlier take is still parked — are PARKED under screenRecFile with a fresh screenRecReviewKey; then the review is opened over the recorded tab. Only SCREENREC_REVIEWED (keep as recorded) or SCREENREC_TRIMMED (cut) makes the worker broadcast the file event the panel uploads on. A parked take also REFUSES a new recording: srecStart checks it before resolving a tab, because starting over would drop a take the tester has not finished with.

One uploader. That file event is a broadcast, so every open panel document would upload the same take. screens/screen-rec.js claims it first (SCREENREC_CLAIM with a per-document token), the worker serializes the claim and its TTL in screenrec/claim.js, and a failed upload un-claims so the next Retry attach… — here or in another panel — can take it.

Two entries besides the panel’s button also START a recording, and they are the two that come with the activeTab grant the good route needs: the context-menu item and the Alt+Shift+R command. Started from the page there is nowhere to report a refusal, so a parked take answers with its review instead of an error. The recording binds to whatever result the panel last announced through SCREENREC_TARGET.

The take is capped at five minutes — enforced by REC_TIME_CAP_MS inside offscreen/recorder.js; the worker’s SREC_TIME_CAP_MS is the same number kept only for what the bar and the panel say out loud.

When BOTH routes are refused there is a real sentence for it: srecStartHint() (screens/screen-rec.js) turns cast-attach into “another debugger holds that tab (DevTools open?)” and cast-attach-frame into “another extension left a frame on this page” — each naming the two ways to get the good route instead: the keyboard shortcut on the tab, or the context-menu item.


4. The permission model (install-time <all_urls>)

Tester-facing version: user guide §”Site access: allowed everywhere from install”.

manifest.json:

"permissions": ["storage", "sidePanel", "debugger", "scripting", "webRequest",
  "tabCapture", "offscreen", "contextMenus"],
"host_permissions": ["<all_urls>"]

The last three are the screen recording’s (§3.6): tabCapture for the good capture route, offscreen for the document that holds the MediaRecorder, and contextMenus for the Record this tab item — which, with the commands entry beside it, is also how a tester GRANTS tabCapture on a page, since that grant is the invocation and nothing else can stand in for it.

Two things are deliberately absent and must stay absent:

activeTab is absent too, and that one has a consequence. It existed only to make the toolbar click a grant, and the two things that used it — captureVisibleTab and executeScript — are satisfied by the host permission alone. chrome.tabCapture is not: Chrome hands its stream over only where the extension was INVOKED on that tab, and <all_urls> buys nothing there. That is why the screen recording carries a second, debugger-based route at all, and why its two page-side entries matter — they are the invocation.

chrome.tabs.query still returns a tab’s url only where we hold host access, and <all_urls> does not cover chrome://, the Web Store or another extension’s pages. A hidden url therefore means a restricted page — the one verdict a tester can act on, and never “not granted yet”. (originOf() / resolveSiteTab(), shared/site-tab.js.)

Why this model. The per-origin one failed on its own terms: activeTab is per-tab and dies on the next tab switch, and the only permanent grant was a 12-second toast that fired in two flows and once per origin ever — miss it and no UI path remained. Jam 5.76.1, Tango 8.9.3 and Loom 5.5.204 (manifests pulled from the Web Store and read) all ship <all_urls> at install and have no runtime grant UX at all. A tester who wants less narrows it in chrome://extensionsSite access, which is Chrome’s own surface and always available — the panel keeps working and simply reports what it cannot touch.

Deployment. Adding a required host permission disables an extension for every existing Store user until each re-approves. This landed BEFORE the first Store publication precisely to avoid that; unpacked installs apply it silently. The per-permission justification lives in PRIVACY.md.

One consequence worth knowing. evidence/recorder.js registers its four chrome.webRequest listeners at load with urls: ['<all_urls>'], and Chrome delivers webRequest events only for hosts the extension holds. Under the old manifest that meant app.testomat.io plus whatever the tester had granted; now it means every request in the browser wakes the service worker, where evWrOwns() drops all but the recorded tab’s. Nothing is stored and nothing leaves — but it is a real wake-up cost, and the listeners cannot simply be narrowed: an MV3 worker only re-attaches listeners registered synchronously at top level, which is what keeps a recording alive across a worker recycle (the if (chrome.webRequest) block in evidence/recorder.js, with the comment that says exactly this). Narrowing it to the recorded tab needs a start/stop re-registration plus a restore path, i.e. its own design pass.

4.1 resolveSiteTab({verb, activate}) — three states and a bound target

shared/site-tab.js. It resolves the active tab of the window hosting the caller (windows.getCurrent, falling back to lastFocusedWindow — the side panel is per-window) and returns:

state Means The copy the user sees
ok http(s) tab whose url we can read. Carries tab and the port-less origin.
system-page A page extensions are kept off: the url is hidden (chrome://, the Web Store, another extension) or readable but not http(s) (devtools://, file://, …). “Chrome doesn’t allow extensions on this page (chrome://…, the Web Store, another extension’s page), so it can’t be <verb> — switch to the site under test.”
none No active tab, or no extension context. “No active tab — focus the site under test”

verb (“captured” / “recorded” / “shown” / “reviewed”) is what a call site tunes for the copy; activate: true is the other knob, opt-in, taken only by the paths that need the page VISIBLE — a screenshot of a background tab is a screenshot of nothing. The copy names no gesture on purpose: there is no click that could change the answer.

The bound target. Every ok also REMEMBERS its tab: rememberTab() writes {tabId, origin, at} to storage.session under siteTarget. Storage and not a variable, because the worker is torn down between clicks and the panel is a new document after every navigation, yet both have to name the same tab. When the active tab is one we cannot work on, targetTab() stands the remembered one in before the answer is system-page — the tester detoured to a settings page, the site they are testing is still open, and the flow should not lose it. That answer carries viaTarget: true. The binding is dropped when its tab is gone (chrome.tabs.onRemovedforgetTab), never merely because it lost focus.

Window mode is the exception in siteWindowId(). With the panel in a popup window of its own, windows.getCurrent() IS that popup and its only tab is the panel document — every capture and every recorder would target the panel. So the panel’s own window is skipped (ViewMode.isPanelWindow) and the question goes to the most recently focused NORMAL window: the id the worker tracks, else windows.getAll() filtered to type: 'normal' (focused first, newest after). A normal window is never the panel’s, so side-panel mode keeps exactly the old one-query path.

4.2 What is left of the old machinery

Nothing that prompts. ensureSiteAccess() (shared/site-access.js) is a thin {ok, error} shape over resolveSiteTab() for the editor’s two call sites, and that is the whole file. Deleted with the permission rework, and not to be reintroduced:

Gone Was
ensureOriginAccess(url) the contains → request flow behind the Settings instance save and the “Always allow” button, with its user-gesture rule
SiteAccess.offerAlwaysAllow + alwaysAllowOffered the one-time permanent-grant toast, burn-listed per origin
core/site-resume.js the pending-action registry that replayed a blocked feature on the grant signal
SITE_ACCESS_GRANTED + chrome.permissions.onAdded listeners the two signals that drove that replay
srRecover() the worker-side retry those signals called; the blind recorder now revives on tabs.onUpdated’s complete alone
Settings → Allowed websites (+ its CSS and the toast-action button) the only UI that could take a grant back; chrome://extensions → Site access is that surface now

4.3 Two capture paths

chrome.tabs.captureVisibleTab requires <all_urls> or activeTab — a per-origin host grant does not satisfy it (Chrome: “Either the ‘<all_urls>’ or ‘activeTab’ permission is required”), which is why the old model could not use it at all. It is held now, so:


5. Storage

Three areas, plus page-level sessionStorage. Nothing is ever written to chrome.storage.sync.

5.1 chrome.storage.local — survives a browser restart

Key Shape Written by
settings The active instance plus its preferences: {baseUrl, apiToken, projectId (resolved from the token's project list, never typed), envInfoOnFail, envFullUrl, evidenceWindowSec, evidenceAutoStart, evidenceAutoAttach, evidenceCaptureBodies, stepRecNeverValues} (screens/settings.js commitSettings()) and fullPageCapture (the Full page checkbox, TestGates.setFullPageCapture()) screens/settings.js commitSettings(), core/project-switcher.js persistActiveSettings(), screens/test-gates.js
evidenceCaptureBodies The body-capture boolean ALONE, mirrored from the active settings on a save and on a recording start — the in-page relay reads this key, never settings, which holds the API token screens/settings.js, screens/evidence.js
stepRecNeverValues The recorder’s never-record-values boolean ALONE, mirrored from the active settings on a save — the injected content/step-recorder.js reads this key, never settings, for the same reason as the row above. Absent -> OFF, i.e. values are recorded with masking applied screens/settings.js
polishSteps The test editor’s Polish with AI switch (#23), its OWN top-level boolean — it belongs to this browser, not to the instance’s settings, and is written the moment the switch moves (a 401/403 from /prompts writes false and hides it). Absent -> OFF editor/rec-session.js
hostSettings hostname → its saved settings object — switching instances restores that host’s token/project/prefs with no re-entry core/storage.js migrateHostSettings(), screens/settings.js commitSettings(), core/project-switcher.js persistActiveSettings(), screens/settings-erase.js forget()
hostHistory Hosts used before, most-recent-first, deduped (the Instance dropdown) core/storage.js migrateHostSettings(), screens/settings.js commitSettings(), screens/settings-erase.js forget()
session The restorable panel session: {view, activeTab, tabViews, runId, runTitle, currentRecordId, stepTicks, expandedGroups, runsFilter, runInfoOpen} (core/storage.js persistSession(); the last key keeps its name on purpose — a rename would silently lose every existing profile’s choice). Read back through SessionRestore.fromStored(), which guards every field: it is last month’s JSON, written by an older panel core/storage.js persistSession()
offlineQueue recordId → {recordId, runId, status, comment, queuedAt, reason, envMeta, prevStatus, host, projectId} — status writes waiting for connectivity. The host/projectId stamp is the connection the write belongs to: only matching entries replay, the rest wait for theirs (an entry from an older build carries neither and counts as the active connection). envMeta is the environment SNAPSHOTTED at the click, so a replay hours later describes the test and not the drain; prevStatus is what the row showed before the first click of the series, which is what a Discard puts back; reason (network | auth) is WORDING only — the replay treats every entry alike. The recorder’s window is deliberately NOT parked here: up to 1000 entries carrying a 16 KB body each, against this area’s 10 MB, would lose the queued result itself screens/offline-queue.js queueEnqueue()
viewMode 'sidepanel' \| 'window' — which surface the panel opens in. A fact about this browser like theme below: not in settings, committed on the header control’s click, mirrored onto Chrome’s openPanelOnActionClick, and carried back across signOut()’s clear() shared/view-mode.js
theme 'system' \| 'light' \| 'dark' — the Appearance switch. One of the two keys here that are neither a credential nor scoped to one: it is a fact about this browser, so it is not in settings (which is per-host and committed by Save & validate), it commits on the click, and signOut() carries it back across clear() shared/theme.js set()
handoffDeclinedAt The at of a handoff.json offer the tester declined. Deliberately OUTSIDE the host-scoped keys an erase wipes: Disconnect clears those and reloads, and this is the one mark that has to survive that, or the file re-connects the panel on the next boot shared/handoff.js decline()
stepRecIndicatorPos Where the step recorder’s pill was dragged to, {left, top}. Written by the injected indicator, which has no other place to keep it — it is a new document on every navigation content/rec-pill.js
screenRecBarPos The same for the screen recording’s control bar content/rec-bar.js

There is no chrome.storage.sync or chrome.storage.managed use anywhere. localStorage is used for exactly one thing: shared/theme.js mirrors the theme key into it, because chrome.storage answers a tick later than the first paint and the head script has to pin the scheme synchronously. The mirror is never the authority — the chrome.storage.local read that follows it overwrites whatever it said, which is also how an absent key (a fresh profile, a wiped one) lands back on system. (An earlier settings also carried newTcTemplate, the local New-TC markdown blob; new tests are now seeded from the project’s own templates, so nothing about them is stored on the client.)

The one-time migrateHostSettings() (core/storage.js) folds a pre-rework single settings into hostSettings for its own host. It is idempotent.

dropAiApiKey() (core/storage.js, called from app.js boot) removes the aiApiKey an install may still hold from before that feature was removed. The AI polish is gone, so that key is a live secret with nothing left to read it — deleting it at every boot IS the migration, and it is a no-op on a profile that never had one.

persistSession() refuses to write while state.booting is true or before settings exist — a fire-and-forget write from a transient first load would otherwise resurrect a phantom session.

The table also has an exit — three of them, all in screens/settings-erase.js (SettingsErase), which screens/settings.js delegates to. forget() drops one host from hostSettings + hostHistory, and — when it is the active one — the HOST_SCOPED_KEYS (settings, session, offlineQueue) with it, plus the whole of storage.session. disconnect() is that same call aimed at the host in state.settings whatever the Instance field shows. Either one, when the host it erases is the active instance, leaves a handoffDeclinedAt mark if the connection came from a host app’s file — the reload would otherwise take the offer straight back, so the mark is what makes Disconnect stick. signOut() calls clear() on storage.local and storage.session (§5.2 holds the recorded steps, the evidence buffer and the screenshot hand-offs, and survives everything but a browser restart), a whole-area wipe rather than a key list because the finding was that a forgotten key kept a live credential. Two keys are carried back over that wipe: theme (shared/theme.js) and viewMode (shared/view-mode.js) — neither a credential nor scoped to one, and both re-written after clear() rather than exempted from it, so the whole-area wipe stays whole. The sign out first attempts EVIDENCE_WIPE (§3.4): the evidence buffer lives in the worker, so a recording still RUNNING would re-mirror it over the clear ~2 s later. A missing listener is tolerated — no worker, no recording. A refusal or a 5 s timeout does NOT abort the sign out, though: a token is standing access to the project and the buffer is logs, so both areas are cleared anyway and the recorder failure is reported after. Because the erase did happen, the panel still cold-boots to first launch, and the reason rides a one-shot page-sessionStorage breadcrumb (signOutRecorderWarning, §5.4) that SettingsErase.takeWarning() paints onto settings-forget-status — a status line set before reloadPanel() would die with the document. forget() takes the same two steps for the ACTIVE instance only — EVIDENCE_WIPE first, then storage.session.clear() — because that area is scoped to no instance but the panel is being reset anyway, and it holds the recorded steps, the evidence buffer, unsaved editor drafts and pending screenshot hand-offs. Forgetting an INACTIVE instance touches neither: that data belongs to the session the user is still in. The failed-wipe warning is shared (lead names the erase that did happen — “Signed out” / “Instance forgotten”), and the whole message, not just the reason, is what rides the breadcrumb. Storage is written FIRST and in-memory state follows only on success, so a rejected write reports itself on a status line instead of leaving a half-erased panel — settings-forget-status for a Forget, signout-status for a Sign out, which the redesign gave its own line so the failure is not reported inside the collapsed Advanced fold. Both then location.reload(), a cold init() being the only way to be sure no module kept an in-memory copy of what was deleted. state.booting is set over the erase to quiet persistSession(), but it is no barrier — the guards are read at call time, so a dispatched set({session}) can still land after the wipe. Credential-free, and inert once the reloaded panel is unconfigured.

5.2 chrome.storage.session — cleared on browser restart

Key Owner Holds
stepRec background.js srSet() The canonical step-recording state (see §3.5). Session on purpose: an SW restart keeps it, a browser restart drops it.
screenRec screenrec/session.js srecSet() The live screen recording: {recording, paused, tabId, recordId, mode, startedAt}, plus framesOut on the cast route. mode is tab or cast — every teardown path has to know whether there is a chrome.debugger attach to give back, and framesOut whether foreign iframes are waiting to be put back (§3.6).
screenRecFile screenrec/session.js srecFinish() / the claim + review handlers The PARKED take, from the moment a recording stops until it is attached or discarded: {url, size, ms, reason, name, recordId, reviewed} plus the claim a panel document holds (screenrec/parked.js, screenrec/claim.js). It is what makes a new recording refuse to start, and it is not the tester’s work thrown away when a panel closes.
screenRecReviewKey screenrec/session.js srecFinish() A fresh UUID per parked take. Any page may frame screenrec/review.html, and only our own overlay is ever handed this key, so it is how that page tells the extension framed it from the page under test having done so. Its OWN key: the parked record above is broadcast, this is not.
screenRecTarget screenrec/session.js, SCREENREC_TARGET from the panel Which testrun a recording started FROM THE PAGE binds to — the hotkey and the context menu have no panel state to read.
commentDrafts screens/test-drafts.js CommentDrafts recordId → {text, runId} — the comment box is READ only by a status write, so everything else that leaves a test used to throw the typing away. Session and not local: it outlives navigation and a closed panel, and dies with the browser rather than reaching disk. The runId is what lets the prune tell “this run dropped the result” from “the tester is in another run”.
siteTarget shared/site-tab.js rememberTab() {tabId, origin, at} — the last tab a resolveSiteTab answered ok for, so a tester who detours to a page we cannot work on does not lose the site under test (§4.1).
openRunIntent background.js’s OPEN_RUN handler {url, at} — the web app’s Run in Extension click, spent by whichever panel wakes up next (core/open-run-intent.js, which drops one older than 60 s).
fileOverlay background.js openFileOverlay() {url, name, type, at} — the file content/file-overlay.js is about to frame.
handoffOpenedAt shared/handoff.js The at of the last run a handoff.json offer opened, so a reload restores the tester’s own place instead of jumping back to whatever the host last asked for.
evidenceMirror evidence/recorder.js evMirror() {session, buffer, windowSec} — the recorder’s throttled mirror so an SW restart recovers. A COPY of the worker’s buffer, so removing the key is not enough on its own: EVIDENCE_WIPE stops the recording first (§3.4).
viewPanelWindowId shared/view-mode.js, written by background.js The panel’s own popup window. One panel, not a stack: the next icon click focuses it. Removed when the window closes.
viewNormalWindowId shared/view-mode.js, written by background.js windows.onFocusChanged The last focused NORMAL window — where the site under test is. What activeTab() resolves against in window mode (§4.1); windows.getAll() is the fallback when it is missing or stale.
annotate-<uuid> shared/capture-annotate.js annotateImage() The screenshot handoff; the annotator overwrites the same key — {ready:true}/{error} while it starts, then {resultDataUrl} or {cancelled:true} (overlay/annotate-overlay.js, editor/annotate.js).
editorDraft:suite:<id> / editorDraft:test:<uid> editor/draft.js editorDraftKey() / makeDirtyTracker() {title, markdown, priority, suite, ts, shots, params?, recording?} — an unsaved test in panel context: suite: for one being created, test: for one being edited (§1.3). recording (#23) is {entries, start, count, polished, rawItems, polishedItems} — the recording the editor was holding, so a reopened panel can still polish it (or put it back) even though the steps are already in the body. shots is a COUNT of the annotated screenshots staged when the draft was written, not the pictures: one is a full-page JPEG data URL of half a megabyte and up, and this area’s ~10 MB is shared with everything else here, so the images go to an IndexedDB database of their own — testomat-shots, wrapped by shared/shot-store.js (ShotStore.put/get/del/sweep) and keyed by the same draft key. The count is what survives a store that lost them: a restored draft can still say how many shots did not come back. That database outlives the browser session this area does not, so background.js sweepStagedShots() drops every record no surviving editorDraft: key claims, and every record older than SHOTS_MAX_AGE_MS (seven days), on runtime.onStartup/onInstalled.

⚠️ The worker calls chrome.storage.session.setAccessLevel({accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS'}) (near the top of background.js, right after the OPEN_RUN handler) so the injected annotator overlay and the file overlay can read their handoff keys. That opens all of storage.session to every content script in every page for the life of the browser session — every other key in the table above inherits that trade silently, including the evidence buffer’s mirror and the unsaved test drafts. This is rake 7.

5.3 e2e-only hooks (production code paths)

Three keys in chrome.storage.session that production code branches on, plus one message. They are live code in the shipped extension, not a test-only build:

Key Effect Read at
stepRecCap Overrides the 50-step recorder cap background.js srCap()
pollInterval Overrides the 20 s live-sync tick screens/livesync.js readPollMs()
forceWriteFail null \| 'network' \| 'auth' — synthesises a real ApiError inside the status-write path so the offline-queue enqueue is exercised deterministically read by screens/offline-queue.js forcedError() (seeded at init and kept fresh by a storage.onChanged listener), thrown by core/write-status.js writeStatus() before the real request

Plus the STEPREC_PEEK message (§2.1), which has no production sender.

Nothing writes these keys in normal use — the e2e suite that does is maintained in the private working repo. If you add a hook, follow the same shape: read from chrome.storage.session, fall back to the production default, and say so in a comment.

5.4 Page sessionStorage

tcReturn — a one-shot {suiteId, suiteTitle} breadcrumb written by openEditor() (screens/tc-studio.js) and consumed by SessionRestore.takeTcReturn() at the panel’s next boot (app.js init()), so returning from the test page lands back on the right suite’s TC list. The test page itself READS it without consuming it (tcReturn() in editor/editor.js), because its trail needs the suite name and the panel’s boot still needs the key. Panel document only. It exists because the panel navigates away to that page rather than embedding it — the panel document is destroyed and rebuilt.

signOutRecorderWarning — a one-shot reason string written by SettingsErase.leaveWarning() when an erase’s EVIDENCE_WIPE failed, and consumed by SettingsErase.takeWarning() off fillSettingsForm() (screens/settings.js), for the same reason: the erase succeeded and the panel reloads, so the warning has to outlive the document that raised it. Not one of the areas sign out erases, carries no credential, and dies with the browser — which is also when an un-erased storage.session buffer dies.


6. The two API legs: v2 token vs JWT JSON:API

extension/api.js is the single client, over six files it loads first: api/errors.js (ApiErrors — the ApiError shape and the copy each refusal gets), api/transport.js (ApiTransportrawFetch, its timeout budget and the 429 backoff whose rateLimitedAt() live sync reads), api/paging.js (ApiPaging — draining an index, the fan-out limit and the runaway guard), api/people.js, api/normalize.js (the v2 → panel record shapes) and api/assets.js (ApiAssets — which URL gets a request at all, and which gets the session’s JWT). It speaks two protocols to the same instance:

  Public API v2 Web JSON:API
Base {baseUrl}/api/v2/{projectId} {baseUrl}/api/{projectId} (project) and {baseUrl}/api (root)
Auth a project key as Bearer — the handoff’s, one minted earlier, or a General token, which reaches every project (v2Token()) a JWT — the session in hand, or one from POST /api/login with {api_token}
Shape flat snake_case dasherized JSON:API documents
Entry points request() / pagedData() jwtRequest() / jwtRequestRoot() / uploadTo()

One credential covers both. What the tester supplies is an account session — a JWT, or a General token that login() exchanges for one — and a session can read any project’s own v2 key on demand (GET /projects/{slug}attributes.api-key). So v2 keys are MINTED, per project, held in memory for the boot and never typed. A role with no API access answers that read fine and simply carries no key, which is its own message (“This project has no API key for your role”). A handed-off config (shared/handoff.js) is the same session arriving from a host app instead of a paste box, sometimes with one project’s key alongside it.

Two consequences of a minted key, both in request(): only a key WE minted is worth replacing behind the tester’s back, so a 401 on one drops it, mints a fresh one and replays exactly once (remint: false on the replay is what stops a loop — an owner rotating the project key otherwise leaves every open panel holding a dead one); and a 403 is corroborated by one cheap independent read (projectIsReadonly()) before it is believed, because a proxy, WAF or SSO gateway refuses ONE route while a genuinely read-only project refuses every one.

v2 leg (always available once configured): list/get runs, rungroups, testruns, tests; set a test status (setStatus); suite/folder and TC creation (TC Studio); the run checklist. This is why the core run loop keeps working with no session. The suite tree moved off this leg (below).

JWT leg: tri-state server-persisted steps (setStep), priority, substatus (setSubstatus/clearSubstatus — the options come from the project’s run-replies; the run’s per-value counters from getRunInfo, i.e. the JSON:API run detail’s substatuses-counts, which v2 does not serve — the same read also returns the four Run info fields v2 omits, ci-build-url / duration / launched-at / finished-at — plus whoever the payload names as the run’s executor/creator, read defensively across the shapes a person can arrive in (runPeopleOf) and simply absent when it names none), assignee (assignTestrun, listProjectUsers), finishRun, the dashboard runs list (fetchDashboardPage + group children/nested/ subgroups), the suite tree (getSuiteTreeGET /suites/tree — the server builds it in abs_position order, and getSuiteTreeOrdered re-sorts every level by the position read off the JSON:API /suites pages, the web’s own key (#26); the Tests tab and the “New test” picker share that ordered read), the header project switcher (listProjects), the project’s New Test templates (listTemplates), a test’s parameters and example rows (getTestParams / setTestParams / createExample / updateExample / deleteExample — v2 serializes neither, so the editor’s grid and the view’s table hide themselves in basic mode), the reported-result summary of an open test (it reads the detail probeSession prefetched, plus a lazy GET /testruns/{id}/steps for an automated row), the recorder’s AI polish (polishRecordedStepsPOST /prompts with prompt: 'polish_recorded_steps', the answer’s data.polished_steps being the rewritten section — #23), and every upload (uploadAttachment / uploadTestAttachment — the v2 attachments route is not deployed on prod).

listTemplates(kind) carries two server quirks worth knowing before touching it (Api::TemplatesController#index, verified live): ?kind= falls back to every standard kind when nothing matches it — so the kind is re-checked client- side — and attributes.document (the recombined body) is only ever filled when a linking record id is passed (?test_id=, ?suite_id=, …). A test being created has none, so attributes.body is what seeds the editor.

The JWT is memory-only (the jwt binding at the head of api.js) and configure() resets it — and drops the minted v2 keys only when the instance or the credential changed; a project switch keeps them — so every panel reload costs one POST /api/login and passes through 'unknown'. jwtSend() re-logs in and retries once on both 401 and 403 (an expired JWT answers 403 per contract). A session the panel was HANDED is adopted rather than exchanged — there is nothing to exchange it for — and only once: re-entering login() with the same dead token would re-arm jwtAvailable and nothing would ever degrade.

6.0 Read-only access is a third state

v2 answers 403 to every request, GET included, for a role that may not write — a reader, a company-readonly account, an archived project — while a rejected token is a 401. JSON:API keeps its GETs open for the same role, so one cheap v2 read is the whole detection. TestomatAPI.readonlyAccess() is the client’s own tri-state ('unknown' | true | false, reset by configure()), cleared only by a 2xx; probeReadonly() / readonlyGate() (core/state.js) settle it, and every screen entry gates on it. What the tester gets is a blocking panel (Gates.applyReadonlyBlock(), core/gates.js) with Settings and the project switcher as the way out — there is nothing to show, so nothing is shown — plus a slow watch (READONLY_RECHECK_MS, 60 s) whose only job is to notice the role changing back.

6.1 jwtAvailable() is a tri-state — do not coerce it

The jwtAvailable binding in api.js — the string 'unknown', or the booleans true / false:

document.body.dataset.jwt is set to available / degraded / unknown by applyCapabilities() (core/state.js) and CSS keys off it. readonlyAccess() is a second, independent tri-state read in the same function (§6.0).

⚠️ capabilities.jwt is a derived boolean with four independent writers — probeSession() (core/state.js, on test open), probeRunSession() (screens/run-view.js, on run open), loadRuns() (screens/runs-list.js, which sets it true on the dashboard read and false in the catch that falls back to v2) — each calling applyCapabilities() — and resetProjectScopedState() (core/state.js), which sets it false for the project about to be probed. There is no subscription; read capabilities.jwt directly. Comparing jwtAvailable() loosely, or coercing it to a boolean, silently breaks the 'unknown' behaviour.

6.2 What degrades in basic mode

Steps become local-only checkboxes (state.stepTicks, per record id) instead of tri-state server-synced rows; Finish run is visible-but-disabled with a reason; priority, custom status and assignee are unavailable (the in-test select, the run-row pill and the run header’s counters go together); the reported-result summary of an already-reported test is absent; the runs list falls back from the dashboard union to plain v2 runs + rungroups; screenshots, local files, screen recordings and evidence logs cannot upload; a test’s parameters and example rows are gone with the reads that serve them; and the run’s archived lock cannot be seen at all (§3.2b). Setting statuses and comments keeps working — that is the whole point of the split.


7. The two chrome.debugger sessions: a shot, and a screencast

The extension attaches a debugger in exactly two places. They are not alike, and the difference is how long the attach stands:

The evidence and step recorders hold no session at all; the evidence one used to, and does not.

Consequences:


8. Live sync and the offline queue

Live sync (screens/livesync.js) is a 20 s poll, not a push. It refetches the same v2 listTestruns payload the run view already loads, so it works in basic mode. Remote-wins diff keyed by record id, repainting changed rows in place; it never touches the comment draft or local step ticks. Ticks self-gate on view + document.visibilityState + the read-only lockout (syncShouldPoll() — under the lockout nothing is on screen to keep fresh), pause while the tester’s own write is in flight (syncBeginWrite/syncEndWrite), and park permanently on a poll 401/403 until the next openRunView. A locally queued status counts as an own-write, so the queue wins over a remote snapshot.

Two more reads ride the same tick, both best-effort, so neither can park the loop or blank what is on screen. Under a session, refreshRunInfo() re-reads the JSON:API run detail: the custom-status counters, the Run info fields and the archived flag live there and not in the rows — a colleague’s substatus write moves no status, so the header would never catch up otherwise. In basic mode refreshRunFinished() re-reads the v2 run detail instead, which is the one signal a token-only panel has that the run was finished elsewhere. Then RunLock.applyRunLock() runs unconditionally, so a remote finish, an automated flip or an archive engages the lock within one poll interval.

The interval is not always 20 s. syncTargetMs() reads TestomatAPI.rateLimitedAt(): while the instance’s last answer was a 429, the tick drops to one a minute (SYNC_RATE_LIMIT_MS), because polling a rate-limited instance at the usual rate is what keeps it rate-limiting us. A 2xx clears the stamp and the next re-arm returns to the ordinary interval. syncArm() is the ONE place the timer is armed, so the two can never drift apart.

True ActionCable push is blocked on product-server work — see §9, rake 4.

Offline queue (screens/offline-queue.js) catches a network error or a transient 401/403 on a status write only, keeps the optimistic local status, persists the entry in chrome.storage.local and replays it on the next panel open / run open / successful poll tick / online event. Assign, custom status, finish, steps and attachments still fail honestly. It drains from the panel only — a closed panel means the queue waits. One entry per record, newest click wins. The queued comment is the raw tester text — which is all the message ever holds. Replay goes back through WriteCore.writeStatus, with {noQueue: true, replay: true} so a failed retry throws and stays queued rather than re-queueing itself. The env meta is not re-collected at replay: each entry carries the envMeta snapshotted at the click, because a drain hours later would otherwise describe whatever tab happens to be open then. The recorder’s window is not parked with the entry either, so a replay attaches no console & network log — and the queue says so, in one line, when it syncs a result that has none. Before replaying it re-checks each target run’s write lock and drops what it must not write — see §3.2b.


9. Known rakes

These are the ones that will bite first.

1. Script load order is the dependency graph, and nothing enforces it. The 73 <script> tags at the foot of sidepanel/index.html ARE that graph. Every top-level const/let/function in those files shares one scope. core/state.js must precede anything touching state; app.js must stay last. Modules reading a binding declared in a later-loaded file already exist — screens/run-lock.js settlePendingWrites() awaits stepWriteChain, a top-level let in screens/test-view.js, which loads seven tags after it; core/gates.js calls setImmersive() and updateContextBar() from core/views.js, which loads after it too. Both are safe only because they run at paint time and never during load; each file says so in a comment where it does this. Reorder those tags and it becomes a temporal-dead-zone ReferenceError. Add new files at the end of the list, before app.js.

2. runsFilter vs runFilter — one letter, two different things. state.runsFilter is the runs-list chip (all|passed|failed|running| scheduled|terminated, RUN_FILTERS in screens/runs-list.js — the order is load-bearing: Fit.filterChips() hides the RIGHTMOST first) and it is persisted in the session object. state.runFilter is the run-view chip (all|passed|failed|skipped|untested) and it is in-memory only, reset when a DIFFERENT run opens (re-opening the one on screen keeps chip, search and folding). Same trap for runsSearch/runSearch — and runsSearch now outlives leaving the runs list too, cleared by a project switch and by resetRunsSearch() on the three URL-intent paths. Grep before you touch either.

3. Record id is not test_id. Rows are keyed by testrun record id throughout — a parametrized test case has one record per example row and they all share test_id (recordFor(), core/state.js). recordFor() compares stringified because ids cross the session boundary as numbers or strings. Every diff, cache and repaint in livesync.js and run-view.js keys on record id. Sorting has the same trap and one shared answer: byRecordId() — numeric ids as numbers, anything else as text after them — is what all three run-order sorts must use, because a plain > puts '10' before '9'.

4. Real-time push is not available, and it is not your bug. Live sync is a 20-second poll on purpose. ActionCable from an extension is blocked by two product-server facts, both verified against a live instance: the production origin allowlist rejects a WebSocket handshake from chrome-extension://<id> before auth ever runs, and /cable authenticates via the Devise/Warden cookie only, which an extension WS cannot reliably present. It is open and blocked on product-server work. Do not spend a day rediscovering this.

5. jwtAvailable() is a tri-state. See §6.1. 'unknown' is not false.

6. setPanelBehavior({openPanelOnActionClick}) + the action.onClicked handler are one unit. syncPanelBehavior() in background.js, and the chrome.storage.onChanged listener beside it. The value is persisted per installation, so an install that once stored the other mode must be overridden on every worker start; in window mode the handler is the only thing that opens anything. That is all the click does — it grants nothing, and there is no grant left to give.

7. chrome.storage.session is readable by every content script. §5.2. Do not put anything in it you would not hand to an arbitrary page.

8. The HTML sanitizer is the only XSS boundary — and nothing calls it directly any more. shared/html-sanitize.js has exactly one call site: Md.render() in shared/markdown.js, which IS the pipeline (escape → strip comments → showdown → sanitize) and answers a DETACHED <div>. Every consumer goes through it — screens/test-view.js for a test body, screens/test-summary.js for a manual result message, and the test page’s renderPreviewInto() for the Preview tab AND the read-only view. TC content is authored in Testomat and can carry raw HTML, so a second path into a live document would be a second boundary; do not open one. It is a drop-list, so it is only half the boundary anyway: manifest.json’s content_security_policy.extension_pages is what stops the markup it deliberately keeps — an <img> or a <video src> planted in a test description — from reaching a third party. It opens with default-src 'none', so a channel nobody enumerated is closed rather than open; img-src is 'self' data: blob:, which is why every product image is fetched and swapped in as a blob: (shared/img-hydrate.js, shared/user-cell.js) rather than linked; and connect-src carries data: and blob: because the panel, the editor, the worker and the review page fetch() their own screenshots and recordings.

9. Chrome’s own Site access UI cannot be automated. Nothing tests it. If you change shared/site-tab.js, shared/site-access.js or the action.onClicked handler, check it by hand in a real Chrome with the extension set to “On click” — that is the one state nothing else covers.

10. Another extension’s frame on the page kills chrome.debugger — and it reads like a permission bug. Measured directly: when any other extension has a frame in the tab (an overlay, a sidebar, a widget), Chrome refuses the attach with “Cannot access a chrome-extension:// URL of different extension” even though the tab is an ordinary https:// page we hold access to. Attaching by targetId is refused identically, an already-open session starts failing the moment such a frame appears, and both work again once it is gone. chrome.scripting.executeScript is unaffected (foreign frames are simply skipped), so neither the step recorder nor the evidence recorder sees this: that immunity is exactly why the recorder was rebuilt on injection. resolveSiteTab answers ok throughout — the tab really is the site tab — so do not go looking in the tab resolution: dbgError() / dbgIsForeignFrame() (shared/dbg-errors.js) are the one place the refusal is translated.

It has two victims, one per debugger user (§7): the full-page screenshot and the screen recording’s cast route. Both take the same cure before giving up — foreignFramesOut() DETACHES the offending iframes (display: none is not enough: the document stays committed and Chrome keeps refusing), retries once, and foreignFramesBack() puts each one back where it was, parent and next sibling included. Re-inserting an iframe reloads it; that is the price. The screenshot then still has the viewport fallback under it, and the recording has srecStartHint(), which names the two page-side entries that reach the good tabCapture route instead. A dead frame left by a disabled or updated extension triggers this as reliably as a live one.


10. Where to make a change

You want to change… Start here
A new API call extension/api.js — and first verify the endpoint against the product’s own source, then curl-smoke it, before any UI code depends on it. The transport, the paging and the error copy are api/transport.js, api/paging.js and api/errors.js; a route’s own function belongs in api.js.
The runs list (filters, groups, URL paste) sidepanel/screens/runs-list.js; remember the two modes, dashboard (JWT) and v2.
The run checklist, suite sections, the run-row custom-status pill + header counters sidepanel/screens/run-view.js.
Finish run, or when a result may be written at all sidepanel/screens/run-lock.js (RunLock) — seven screens and core/write-status.js ask it, §3.2b.
The Run info card sidepanel/screens/run-info.js.
Steps, example substitution, the priority icon, clickStatus sidepanel/screens/test-view.js.
The status write itself core/write-status.jsWriteCore.writeStatus() is the single writer, and its third caller is the offline queue’s replay.
What a disabled verdict button, comment box or attach control says sidepanel/screens/test-gates.js (updateTestActionsState()).
Custom status or assignee sidepanel/screens/test-meta.js.
The reported-result card, or a file tile anywhere sidepanel/screens/test-summary.js — it owns the panel’s one fileTileItem.
The status write’s side effects (env meta, evidence log, queue) core/env-info.js, screens/evidence-upload.js, screens/offline-queue.js — all hang off WriteCore.writeStatus.
Anything that touches the page under test shared/site-tab.js first. Never hand-roll a tab.url check.
A screenshot / annotator change background.js captureShotshared/capture-annotate.jsshared/annotate-core.js (engine, over annot-geometry / annot-history / annot-keys) → overlay/annotate-overlay.js (on-page) or editor/annotate.js (fallback tab).
A screen-recording change screenrec/session.js (the worker’s half and both capture routes) → offscreen/recorder.js (the file) → content/rec-bar.js (the controls) → screenrec/review.js (preview + trim) → screens/screen-rec.js (the panel’s button and the upload).
Reading, creating or editing a TC editor/editor.js (renderEditor()) and editor/view.js (EditorView.renderView()); a separate document that reuses the panel’s globals.
A new panel screen Add sidepanel/screens/<name>.js, a <section id="view-<name>"> in index.html, an entry in views (core/state.js) and in NavModel.TAB_OF_VIEW (core/nav-model.js, plus ROOT_VIEWS if it is a tab root), and the <script> tag before app.js.
A new persisted field Decide local vs session (§5), then update core/storage.js and §5 of this file — those tables drift first.
A navigation decision (which screen a tab click or a Back lands on, what the trail says) core/nav-model.js — pure, no DOM; core/views.js only paints what it decided.