diff --git a/PRIVACY.md b/PRIVACY.md index 7f75b23..60f786c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy policy — Clay Slip -_Last updated: 2026-05-13_ +_Last updated: 2026-05-19_ Clay Slip is a developer tool. It runs entirely on your device, in your browser. **It does not collect, transmit, sell, or share any personal data.** @@ -22,10 +22,10 @@ This document is the canonical privacy disclosure for the extension. It's distri Clay Slip uses the standard WebExtension storage APIs (`chrome.storage` on Chromium, `browser.storage` on Firefox — same shape, same data, same guarantees). Stored data never leaves the user's device or browser-vendor account. -| Storage area | Contents | Why | -| --------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `storage.sync` | UI preferences (theme, panel position/size, site host mappings, highlight mode + intensity, shortcut toggle) | Carries your settings across browsers when you're signed in to Chrome / Firefox Sync. | -| `storage.local` | Sticky-note annotations pinned to component URIs; "recently viewed components" history (capped, configurable) | Keeps notes and history available offline; not synced because they may include page-specific context. | +| Storage area | Contents | Why | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `storage.sync` | UI preferences (theme, panel position/size, site host mappings, window globals list, highlight mode + intensity, shortcut toggle) | Carries your settings across browsers when you're signed in to Chrome / Firefox Sync. | +| `storage.local` | Sticky-note annotations pinned to component URIs; "recently viewed components" history (capped, configurable) | Keeps notes and history available offline; not synced because they may include page-specific context. | You can clear everything from the extension's **Options** page (Reset preferences, Clear history) or via your browser's _Manage extensions_ → _Site access / storage_ controls (Chromium) or `about:addons` → Clay Slip → _Remove_ (Firefox). @@ -38,6 +38,7 @@ To do its job, the content script reads: - The `data-uri` and `data-editable` attributes that Clay sites set on rendered components. - Standard `` metadata (``, `<meta>` tags, `<link rel="canonical">`, JSON-LD) for the SEO tab. - The text/HTML of components you explicitly select for the JSON tab and Diff tab. +- **Only the top-level `window.*` globals you explicitly list on the Options page (Window globals)**, and only when you open the **Globals** tab or click **Refresh**. The extension injects a tiny one-time bridge script into the page's main world, which reads `window[key]` for each configured key, `JSON.stringify`s it, and posts the result back to the panel. The script never reads any global you didn't configure, and never reads anything until you ask the Globals tab for data. This data is **only ever displayed inside the panel on your machine.** It is never sent anywhere except, when you explicitly ask, to the same Clay host the page came from (see "Outbound network requests" below). diff --git a/README.md b/README.md index 8510a8d..6a29aa7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The same source builds for both browser families: - **Shareable selection links** — copy a `?clay-slip-select=…` URL that auto-opens the panel and selects the same component on someone else's machine - **Component screenshot to clipboard** — one-click PNG of any selected component, panel auto-hides during capture - **SEO tab** — title / meta / og / twitter / JSON-LD with a Twitter + Facebook card preview and lints (length, missing image, duplicate `<h1>`, etc.) +- **Window globals tab** — surface any top-level `window.*` value your page sets at boot (e.g. `nymGtmPage`, `dataLayer`, custom analytics payloads) as syntax-highlighted JSON. Configurable list per install; arrays and objects render identically; per-row and tab-level refresh, no ambient polling - **Recently viewed components** persisted across sessions, with one-click jump back - **Resizable + dockable panel** — drag the inner edges (or the inner-corner grabber) to resize width _and_ height; choose any of four corners or a full-height left/right side dock - **Refined highlight modes** — _Off_, _Selection_ (default; pristine page, hover and click highlight in blue, hold <kbd>⌃</kbd> Control to flash the rainbow over every component), _Editable only_ (always-on subtle corner accents on `[data-editable]`), or _All components_ (always-on rainbow over every component, like the original Clay devtools). Hover and selected always paint in a single blue accent — outline + inset tint — so the "you clicked it" feedback reads consistently across every mode, on top of either the rainbow or the corner-accent ambient layer. Top-left labelled badge follows your hover and selection. Switch modes from the panel header dropdown or with the <kbd>h</kbd> shortcut. @@ -140,6 +141,7 @@ Stored preferences/notes can also be cleared from the Options page (**Clear rece | Show shortcut overlay | Press <kbd>?</kbd> | | Toggle FAB ↔ panel | Press <kbd>[</kbd> or click the collapse button / the FAB | | Switch tabs | Press <kbd>i</kbd> (Inspect) or <kbd>t</kbd> (Tree) | +| Read a window global | **Globals** tab → expand the row for the configured global; **↻** re-reads from the current page | | Open settings | Click the gear icon in the panel header | ## Screenshots @@ -170,6 +172,24 @@ Example for a Vox-Media-style multi-brand setup: Hostnames are matched **exactly** (case-insensitive) — no prefix stripping or wildcards — so the mapping does what you wrote and nothing more. There’s no separate global env config; if a host isn’t in any mapping, the extension falls back to whatever host the Clay component URI itself encodes, which is also the page’s host. +### Window globals + +The **Window globals** section on the options page lists the top-level `window.*` keys you want to inspect on every Clay page. Each configured global gets its own collapsible card in the **Globals** panel tab. + +- Enter the name as either `nymGtmPage` or `window.nymGtmPage` — the extension strips the prefix and normalizes the rest. Whitespace around the value is trimmed. +- Both **objects** and **arrays** render identically — the panel just shows their `JSON.stringify` output with syntax highlighting. +- Reads happen on initial tab open and on explicit **Refresh** clicks (per-row or tab-level **Refresh all**). There is no background polling — the page only does work when you ask it to, so this tab has no measurable runtime cost. + +Limitations and edge cases (surfaced inline in the tab): + +- **Top-level only.** Nested paths (`foo.bar.baz`) and array indices (`dataLayer[0]`) aren’t supported yet. Add the top-level global; expand the row to drill into the structure. +- **`(not defined on this page)`** — the global isn’t set on the current page. Most often a navigation timing issue: try Refresh after the page has finished loading. +- **`(value is not JSON-serializable)`** — the global is a function or `Symbol`. `JSON.stringify` can’t round-trip those; nothing the extension can do beyond surfacing the fact. +- **`(could not serialize: …)`** — the value contains a circular reference. The underlying JS error is embedded in the message. +- **`(could not read from this page)`** — a strict Content Security Policy or Trusted Types policy blocked the page-bridge injection. Refresh the page and try again; if it persists, the host page’s CSP is the cause. + +Internally this works by injecting a tiny one-time script into the page’s main world that listens for postMessages and replies with the JSON-stringified value. The script never reads anything you didn’t configure on the options page. Full design write-up: [`docs/specs/2026-05-19-window-globals-tab.md`](docs/specs/2026-05-19-window-globals-tab.md). + ## Build from source For contributors and anyone who wants to run the extension from a local checkout instead of a release zip: diff --git a/docs/specs/2026-05-19-window-globals-tab.md b/docs/specs/2026-05-19-window-globals-tab.md new file mode 100644 index 0000000..8fabe4f --- /dev/null +++ b/docs/specs/2026-05-19-window-globals-tab.md @@ -0,0 +1,184 @@ +# Window Globals tab + +**Status:** approved 2026-05-19 — ready for implementation +**Branch:** `feat/window-globals-tab` (off `master` @ `0641e15`) +**Target:** v2.4.0 (new feature, additive, no breaking changes) + +## Summary + +A new **Globals** tab in the panel that surfaces the values of user-configured `window.*` globals on the host page. Built for inspecting analytics data layers (GTM, Segment, etc.) without opening DevTools. + +The user configures a list of top-level global names on the Options page (e.g. `nymGtmPage`, `window.dataLayer`). The Globals tab renders one collapsible section per configured global, each showing the value as syntax-highlighted JSON. Arrays and objects render identically. Data is read on tab open and on explicit per-section / "Refresh all" clicks — there is no ambient polling. + +## Motivation + +Clay pages routinely carry per-page analytics data on `window.*` globals. Today you have to switch to DevTools, type the path, and re-stringify on each navigation. Surfacing them inside the panel: + +- Lets you eyeball analytics data while inspecting components in the same panel. +- Standardises naming across the team (everyone configures the same set once via Options). +- Works in passive edit mode (the panel still mounts on `?edit=true` pages, this is read-only and DOM-noninteractive — fine to run there). + +## User-facing behavior + +### Options page (`Options.tsx`) + +A new section **"Window globals"** below "Site host mappings". Same UX as the site-host rows: one text input per entry, add/remove buttons. Inline help text: + +> Top-level globals to show in the Globals tab. Enter `nymGtmPage` or `window.nymGtmPage` — both forms work. Values are serialized with `JSON.stringify`, so functions and `Symbol` values are dropped. + +The list persists to `chrome.storage.sync` as `preferences.windowGlobals: string[]`. + +### Globals tab (`GlobalsTab.tsx`) + +- **Position:** last tab in the bar (after Notes). +- **Empty state:** when `windowGlobals.length === 0`, render copy: _"No globals configured. Add some in Options → Window globals."_ with a button that opens the Options page. +- **Populated state:** one `<details>` element per configured global, **all closed by default**. Summary line shows the path name (the normalised form — `nymGtmPage`, not `window.nymGtmPage`). +- **Opening a section** triggers a read for that path if no cached value exists. Already-cached values render immediately; the section also shows a "Refresh" button to re-read. +- **Tab-level "Refresh all" button** in the tab header re-reads every configured path. +- **Resolved values** render via the existing `JsonPreview` (which uses `highlightJson` + the existing copy-button + JSON-syntax-highlighting). Arrays and objects look identical apart from their bracket style — that's the whole point. +- **Unresolved values** (`undefined` on `window`) render as muted text: _"(not defined on this page)"_, with a tooltip explaining common causes (typo, global set after load, page not yet hydrated). +- **Serialization errors** (circular ref, throwing getters) render as muted text: _"(could not serialize: <message>)"_. + +### Tab order + +``` +Inspect | Tree | JSON | Diff | SEO | Notes | Globals +``` + +## Architecture + +### Why a page-bridge is needed + +Content scripts run in an **isolated world** — their `window` is not the page's `window`. Reading `window.nymGtmPage` directly from the content script returns `undefined` even if the page has it. To bridge: + +1. Inject a `<script>` element into the page whose `src` is a `web_accessible_resource`. +2. That script runs in the page's main world. It listens for a `postMessage` from the content script containing a correlation ID + the list of paths to read. +3. For each path, it reads `window[path]`, `JSON.stringify`s the value, posts a `{ correlationId, results }` back. +4. The script removes itself after responding. + +This is the lowest-permission, most-portable approach — works identically on Chromium and Firefox (manifest is already MV3 + has `host_permissions: ['<all_urls>']`, no new permissions required). + +### Files + +| File | Purpose | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/lib/window-globals.ts` (new) | Pure: `parseWindowGlobal(input: string) → { ok: true, key } \| { ok: false, reason }`. Strips an optional leading `window.`, validates the remainder matches `/^[$_a-zA-Z][$_a-zA-Z0-9]*$/`, rejects dots / brackets / whitespace / empty strings. | +| `src/page-bridge/read-globals.ts` (new) | The script injected into the page's main world. ~30 lines. Listens for a single namespaced postMessage, reads keys, stringifies, responds, removes itself. | +| `src/content/window-globals-bridge.ts` (new) | Content-script side. Exposes `readGlobals(paths: string[]): Promise<Record<path, ReadResult>>`. Manages correlation IDs and one Promise per in-flight read. Auto-injects the page script on first use; idempotent. | +| `src/content/panel/components/GlobalsTab.tsx` (new) | The tab. Renders sections, owns per-path read state in component-local state (`Map<path, ReadResult \| 'loading'>`). | +| `src/content/panel/store.ts` (edit) | Extend `PanelTab` union with `'globals'`. | +| `src/content/panel/components/Tabs.tsx` (edit) | Add the new tab button last in the rendered list. | +| `src/options/Options.tsx` (edit) | Add the "Window globals" section. | +| `src/lib/types.ts` (edit) | Extend `UserPreferences` with `windowGlobals: string[]`, default to `[]`. | +| `src/manifest.ts` (edit) | Add the page-bridge script to `web_accessible_resources`. crxjs handles the build-time path resolution. | +| `README.md` (edit) | One bullet in **Highlights**; one row in the **Usage** table. | + +### Data flow (one Refresh click) + +``` +GlobalsTab bridge (content script) page (main world) + │ │ │ + ├─readGlobals(['x'])─► │ │ + │ ├─ensurePageScriptInjected()──►│ <script src="…/read-globals.js"> + │ ├─postMessage({id, paths})────►│ + │ │ │ reads window.x, JSON.stringify + │ │ │ removes self + │ │ ◄─postMessage({id, results}) │ + │ ◄─Promise resolves── │ │ + ├─setState(…) │ │ + └─re-render │ │ +``` + +### Correlation IDs + +Each `readGlobals` call generates a random ID (crypto.randomUUID). The bridge keeps a `Map<id, resolveFn>`. The window message listener resolves the matching promise and deletes the entry. Stale messages (no entry in the map) are ignored. This handles the user spamming "Refresh all" multiple times in quick succession. + +### Origin / source checks on incoming messages + +The bridge's message listener: + +```ts +if (event.source !== window) return; +if (!event.data || event.data.type !== 'clay-slip:globals:result') return; +``` + +Plus the correlation-ID check. We never trust the page to inject phantom results. + +## Edge cases & error handling + +| Case | Behavior | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Path is empty after stripping `window.` | Options page shows inline validation error; never reaches preferences. | +| Path has dots, brackets, or whitespace | Same — caught in the parser, surfaced in Options. | +| `window[key]` is `undefined` | Section renders "(not defined on this page)". | +| `JSON.stringify` throws (circular ref, throwing getter) | Section renders "(could not serialize: <message>)". | +| `window[key]` is a primitive (string, number, boolean, null) | Renders as JSON. `null` shows literally; strings get quoted. | +| `window[key]` contains functions or `Symbol`s | Silently dropped by `JSON.stringify`. The Options help text mentions this. | +| Page-bridge script fails to load (e.g. CSP blocks inline script tag) | Promise rejects with a clear error. Section renders "(could not read: page blocked the bridge — see DevTools console)". | +| Same path configured twice in Options | Parser dedups on save; second entry is silently dropped. | +| Passive (`?edit=true`) mode | Globals tab works normally. It's read-only and doesn't touch the host DOM beyond a transient `<script>` injection. | +| User removes a path from Options while the tab is open | Section disappears on next render (driven off the store). | + +## Cross-browser + +- Manifest changes are inside the existing dual-target system (`src/manifest.ts` + `scripts/firefox-postbuild.mjs`). No new branching. +- `webextension-polyfill` already wraps `runtime.getURL`. +- `JSON.stringify` and `window.postMessage` are spec'd identically. +- Verified by running `npm run release:dry:both` before push — both zips must build and pass validation. + +## Tests + +New tests target the pure logic + the wiring. Browser-runtime behavior (actual postMessage round-trip in a real page) is verified manually as part of the sideload smoke test. + +| File | Tests | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tests/lib/window-globals.test.ts` (new) | `parseWindowGlobal`: accepts `foo`, `window.foo`, `$foo`, `_foo`; rejects empty / whitespace-only / `foo.bar` / `foo[0]` / `123foo` / `foo bar`; strips a single leading `window.` (not multiple); preserves identifiers that legitimately start with `_` or `$`. | +| `tests/content/window-globals-bridge.test.ts` (new) | `readGlobals` round-trip via mocked `postMessage`: resolves with results keyed by path; correlation IDs prevent cross-talk between concurrent calls; stale messages (unknown correlation ID) are ignored; missing paths come back as `{ ok: false, reason: 'undefined' }`. | +| `tests/content/panel/components/GlobalsTab.test.tsx` (new) | Empty state when `windowGlobals` is empty; one `<details>` per configured path; clicking Refresh on a section triggers the bridge for just that path; "Refresh all" triggers all paths; unresolved values render the "(not defined on this page)" placeholder. | +| `tests/options/Options.test.tsx` (extend if exists, else new minimal coverage) | Add row → input validates against parser → save persists to storage; remove row removes it. | +| `tests/lib/storage.test.ts` (extend) | `loadPreferences` defaults `windowGlobals` to `[]` when absent; `savePreferences` round-trips the new field. | + +Goal: existing 176 tests + ~12–15 new = ~190 total. All must pass on the polyfill mock in `tests/setup.ts` (Firefox-compatible by construction). + +## Out of scope (YAGNI) + +These were considered and explicitly dropped for v1: + +- **Dot-paths / nested access** (`window.foo.bar`). Confirmed not needed — top-level globals cover the analytics use case. Easy to add later by upgrading the parser. +- **Live updates** (polling / `dataLayer.push` wrapping). Manual refresh is sufficient and avoids any ambient cost on the host page. +- **Per-section truncation** for very large objects. Will revisit only if someone actually hits a slow stringify. +- **Export / "Copy as cURL" for globals.** The existing `JsonPreview` already has a copy button; that's enough. +- **Persisting the last-read snapshot across panel reopen.** Read on open is fast enough that caching adds more complexity than it saves. + +## Performance + +Documented in the previous design conversation; reproduced here for the record: + +- **Zero ambient cost** while panel is closed, while Globals tab is inactive, or while no reads are in flight. +- **Per-Refresh cost** is dominated by `JSON.stringify`: < 0.1ms for small analytics objects, ~1–3ms for a 50-event GTM `dataLayer`, tens of ms only for pathological multi-MB objects. All on the main thread, only on explicit user action. + +## Validation gate + +Before pushing: + +```sh +npm run validate # typecheck + lint + format + all tests (~190) +npm run release:dry:both # both zips build cleanly; manifest shapes correct +``` + +Manual smoke test (load `dist/` unpacked, browse to a Clay page with `window.dataLayer` set): + +1. Configure `dataLayer` and `nymGtmPage` in Options. +2. Open the Globals tab — both sections present, both collapsed. +3. Open the first section — read fires, JSON renders. +4. Click Refresh on the section — read fires again, JSON re-renders. +5. Remove a path in Options — section disappears. +6. Configure a deliberately-missing path (`thisDoesNotExist`) — section shows "(not defined on this page)". + +## Risks + +| Risk | Mitigation | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Strict-CSP pages may block the injected `<script>` (e.g. `script-src 'self'`) | Catch the load error, render the per-section "(could not read)" message. Document in README troubleshooting. | +| Pages using a Trusted Types policy may reject `<script>.src` assignment | Same — clear error in the UI. | +| Future support for nested paths would require parser + bridge changes | Designed so the parser is a single pure function; expanding it is a contained change. | diff --git a/src/content/panel/App.tsx b/src/content/panel/App.tsx index 4e42d75..ba33434 100644 --- a/src/content/panel/App.tsx +++ b/src/content/panel/App.tsx @@ -22,6 +22,7 @@ import { ResizeHandle } from './components/ResizeHandle'; import { RecentList } from './components/RecentList'; import { NotesTab } from './components/NotesTab'; import { SeoTab } from './components/SeoTab'; +import { GlobalsTab } from './components/GlobalsTab'; export function App() { const headerRef = useRef<HTMLDivElement>(null); @@ -120,6 +121,7 @@ export function App() { {activeTab === 'diff' && <DiffView />} {activeTab === 'seo' && <SeoTab />} {activeTab === 'notes' && <NotesTab />} + {activeTab === 'globals' && <GlobalsTab />} </div> <ResizeHandle mode="width" /> {!isSideDock && <ResizeHandle mode="height" />} diff --git a/src/content/panel/components/GlobalsTab.tsx b/src/content/panel/components/GlobalsTab.tsx new file mode 100644 index 0000000..66cdc77 --- /dev/null +++ b/src/content/panel/components/GlobalsTab.tsx @@ -0,0 +1,297 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { highlightJson } from '@/lib/json-highlight'; +import { readGlobals, type ReadResult } from '@/content/window-globals-bridge'; +import { useCopyAction } from '../hooks/useCopyAction'; +import { useStore } from '../store'; +import { failureMessage, summarizeValue } from './globals-format'; +import { Icon } from './Icon'; + +/** + * Each row in the Globals tab is one user-configured `window.<key>`. + * The map is keyed by the normalized key (no `window.` prefix); the + * value is `null` while loading and a {@link ReadResult} once the + * page-bridge has responded (or timed out). + */ +type ResultMap = Readonly<Record<string, ReadResult | null>>; + +/** + * Pre-parse a successful read into the structured value once, so each + * re-render of the card doesn't reparse the JSON string. Cached on the + * row object alongside the raw `json` string the user can copy. + */ +interface ParsedSuccess { + readonly raw: string; + readonly parsed: unknown; + readonly parseError: string | null; +} + +function parseJson(raw: string): ParsedSuccess { + try { + return { raw, parsed: JSON.parse(raw), parseError: null }; + } catch (err) { + // Should be impossible — the bridge calls JSON.stringify before sending, + // so the round-trip is guaranteed valid. But cheap defense-in-depth in + // case a future bridge change forgets to stringify. + return { raw, parsed: null, parseError: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * One collapsible row for one configured global. Defaults to collapsed + * even when data is available — the user opts in to the body to keep + * the tab scannable when there are many globals configured. + * + * Reuses the same `.cs-jsonld-*` class family as the SEO tab's JSON-LD + * viewer so the visual language is identical for "expandable JSON card". + */ +function GlobalRow({ + globalKey, + result, + onRefresh, +}: { + globalKey: string; + result: ReadResult | null; + onRefresh: () => void; +}) { + const [open, setOpen] = useState(false); + const { copy, copiedKey } = useCopyAction(); + const copied = copiedKey === 'default'; + + const parsed: ParsedSuccess | null = result && result.ok ? parseJson(result.json) : null; + + // Mini-summary on the collapsed header so the user gets some signal + // without having to expand. For success: "object · 14 keys" / "array + // · 7 items" / "string". For failure: short reason text. + const secondary = (() => { + if (result === null) return 'Loading…'; + if (!result.ok) return failureMessage(result); + if (!parsed || parsed.parseError) return '(bridge response unparseable)'; + return summarizeValue(parsed.parsed); + })(); + + const onCopy = (e: React.MouseEvent) => { + // Prevent the <summary> click from also toggling the details element. + e.preventDefault(); + e.stopPropagation(); + if (!result) return; + if (!result.ok) { + void copy(failureMessage(result), 'Status'); + return; + } + // Reformat the bridge's compact JSON.stringify output into the + // pretty-printed form the user actually wants on their clipboard. + // Falls back to the raw string if reparsing somehow fails. + const text = parsed?.parseError ? result.json : JSON.stringify(parsed?.parsed, null, 2); + void copy(text, globalKey); + }; + + const onRefreshClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onRefresh(); + }; + + const cardClasses = ['cs-jsonld-card']; + if (result && !result.ok) { + if (result.reason === 'undefined' || result.reason === 'bridge-unavailable') { + // Neutral-ish failures (the page just doesn't have this global, + // or the bridge couldn't run) get a warn-tinted border, not the + // alarming error border — neither is actually a bug. + cardClasses.push('cs-jsonld-card-has-warn'); + } else { + cardClasses.push('cs-jsonld-card-has-error'); + } + } + + return ( + <details + className={cardClasses.join(' ')} + open={open} + onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)} + > + <summary className="cs-jsonld-summary"> + <span className="cs-jsonld-chevron" aria-hidden="true"> + ▶ + </span> + <span className="cs-jsonld-type" title={`window.${globalKey}`}> + window.{globalKey} + </span> + <span className="cs-jsonld-secondary" title={secondary}> + {secondary} + </span> + <button + type="button" + className="cs-icon-btn" + onClick={onRefreshClick} + aria-label={`Re-read window.${globalKey} from the page`} + title="Re-read from the page" + > + <Icon name="refresh" /> + </button> + <button + type="button" + className={`cs-icon-btn cs-jsonld-copy ${copied ? 'cs-icon-btn-copied' : ''}`} + onClick={onCopy} + aria-label={copied ? `${globalKey} copied` : `Copy ${globalKey} to clipboard`} + title={ + copied + ? 'Copied!' + : result && result.ok + ? 'Copy pretty-printed JSON' + : 'Copy status message' + } + > + <Icon name={copied ? 'check' : 'copy'} /> + </button> + </summary> + {/* Body is only mounted once expanded — defers the + syntax-highlight regex pass on huge dataLayer payloads + (10k+ entries on some analytics setups) until the user + actually asks to see them. */} + {open && ( + <> + {result === null ? ( + <div className="cs-loading"> + <span className="cs-spinner" /> Reading window.{globalKey}… + </div> + ) : !result.ok ? ( + <div className="cs-jsonld-body"> + <p className="cs-jsonld-error">{failureMessage(result)}</p> + </div> + ) : parsed && parsed.parseError ? ( + <div className="cs-jsonld-body"> + <p className="cs-jsonld-error"> + Bridge returned unparseable JSON: <code>{parsed.parseError}</code> + </p> + <pre className="cs-jsonld-raw">{result.json}</pre> + </div> + ) : ( + <pre + className="cs-json cs-jsonld-body" + dangerouslySetInnerHTML={{ __html: highlightJson(parsed?.parsed) }} + /> + )} + </> + )} + </details> + ); +} + +/** + * Renders the list of configured globals (or an empty-state CTA if + * none configured), plus a tab-level "Refresh all" button. + * + * Data flow: + * 1. On mount, fire one `readGlobals(keys)` and seed `results`. + * 2. On configured-keys change (the user adds/removes a global on + * the Options page), drop stale entries and re-fetch the new + * list. Existing entries that survived keep their cached value + * so the user doesn't see a spinner-of-everything on every edit. + * 3. On per-row Refresh, re-fetch just that key. + * 4. On tab-level "Refresh all", re-fetch all keys. + * + * No ambient polling — every read is user-triggered. Matches the + * design contract documented in `docs/specs/2026-05-19-window-globals-tab.md`. + */ +export function GlobalsTab() { + const configured = useStore((s) => s.preferences.windowGlobals); + const pushToast = useStore((s) => s.pushToast); + const [results, setResults] = useState<ResultMap>({}); + + // Track which keys we've already fetched on this tab open, so the + // configured-keys diff effect doesn't re-fetch the world every + // time the prefs reference changes (e.g. after an unrelated + // preference save elsewhere in the panel). + const fetchedKeysRef = useRef<Set<string>>(new Set()); + + const fetchKeys = useCallback(async (keys: readonly string[]) => { + if (keys.length === 0) return; + // Seed loading state for the keys we're about to fetch — keeps + // siblings' cached values intact. + setResults((prev) => { + const next = { ...prev }; + for (const k of keys) next[k] = null; + return next; + }); + + const r = await readGlobals(keys); + setResults((prev) => ({ ...prev, ...r })); + for (const k of keys) fetchedKeysRef.current.add(k); + }, []); + + // Configured-keys lifecycle. Effects fire on every `configured` + // identity change; we de-dupe via fetchedKeysRef so unchanged keys + // don't refetch. + // + // We deliberately do NOT prune `results` when a key is removed — the + // rendered list iterates over `configured`, so removed entries are + // simply not displayed. The few orphaned bytes in the results map + // are cheaper than the cascading-render the React-19 rule warns + // about when calling setState from an effect. + useEffect(() => { + // Clean the seen-set so a remove-then-readd refetches the value + // (the user may have refreshed the page between configurations). + const surviving = new Set<string>(); + for (const k of configured) if (fetchedKeysRef.current.has(k)) surviving.add(k); + fetchedKeysRef.current = surviving; + + const toFetch = configured.filter((k) => !fetchedKeysRef.current.has(k)); + if (toFetch.length > 0) void fetchKeys(toFetch); + }, [configured, fetchKeys]); + + const refreshOne = useCallback( + (key: string) => { + void fetchKeys([key]); + }, + [fetchKeys] + ); + + const refreshAll = useCallback(() => { + if (configured.length === 0) return; + // Force re-read of everything: clear the seen-set so fetchKeys + // re-seeds loading state for each row. + fetchedKeysRef.current = new Set(); + void fetchKeys(configured); + pushToast(`Re-read ${configured.length} global${configured.length === 1 ? '' : 's'}`, 'info'); + }, [configured, fetchKeys, pushToast]); + + if (configured.length === 0) { + return ( + <div className="cs-empty"> + No window globals configured yet. + <br /> + Open the extension <strong>Options</strong> page and add a key (e.g. <code>nymGtmPage</code> + ) to see its value here. + </div> + ); + } + + return ( + <section className="cs-section"> + <div className="cs-section-header"> + <h4 className="cs-section-title"> + Window globals <span className="cs-section-count">{configured.length}</span> + </h4> + <button + type="button" + className="cs-icon-btn" + onClick={refreshAll} + aria-label="Re-read all configured globals from the page" + title="Re-read all" + > + <Icon name="refresh" /> + </button> + </div> + <div className="cs-jsonld-list"> + {configured.map((key) => ( + <GlobalRow + key={key} + globalKey={key} + result={results[key] ?? null} + onRefresh={() => refreshOne(key)} + /> + ))} + </div> + </section> + ); +} diff --git a/src/content/panel/components/Icon.tsx b/src/content/panel/components/Icon.tsx index 280c452..0a91823 100644 --- a/src/content/panel/components/Icon.tsx +++ b/src/content/panel/components/Icon.tsx @@ -169,6 +169,28 @@ const ICONS = { strokeLinejoin="round" /> ), + // Circular-arrow refresh glyph. Used by the Globals tab on the + // per-row "Re-read" button and the tab-level "Refresh all" button. + // Single arc + arrowhead keeps the icon legible at 14px. + refresh: ( + <> + <path + d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" + stroke="currentColor" + strokeWidth="1.5" + fill="none" + strokeLinecap="round" + /> + <path + d="M13 2.5V5h-2.5" + stroke="currentColor" + strokeWidth="1.5" + fill="none" + strokeLinecap="round" + strokeLinejoin="round" + /> + </> + ), } as const; export type IconName = keyof typeof ICONS; diff --git a/src/content/panel/components/Tabs.tsx b/src/content/panel/components/Tabs.tsx index 1c93508..f7fcacc 100644 --- a/src/content/panel/components/Tabs.tsx +++ b/src/content/panel/components/Tabs.tsx @@ -8,12 +8,18 @@ const TABS: ReadonlyArray<{ id: PanelTab; label: string }> = [ { id: 'diff', label: 'Diff' }, { id: 'seo', label: 'SEO' }, { id: 'notes', label: 'Notes' }, + // Globals is appended last per the spec — it's the most niche tab + // (only useful once the user has configured at least one global on + // the Options page) and putting it at the end keeps the daily-driver + // tabs in their existing positions so muscle memory isn't disturbed. + { id: 'globals', label: 'Globals' }, ]; export function Tabs() { const activeTab = useStore((s) => s.activeTab); const setActiveTab = useStore((s) => s.setActiveTab); const annotationCount = useStore((s) => s.annotations.length); + const globalsCount = useStore((s) => s.preferences.windowGlobals.length); return ( <div className="cs-tabs" role="tablist"> @@ -29,6 +35,9 @@ export function Tabs() { {tab.id === 'notes' && annotationCount > 0 && ( <span className="cs-tab-badge">{annotationCount}</span> )} + {tab.id === 'globals' && globalsCount > 0 && ( + <span className="cs-tab-badge">{globalsCount}</span> + )} </button> ))} </div> diff --git a/src/content/panel/components/globals-format.ts b/src/content/panel/components/globals-format.ts new file mode 100644 index 0000000..6ddcf78 --- /dev/null +++ b/src/content/panel/components/globals-format.ts @@ -0,0 +1,61 @@ +/** + * Pure formatting helpers for the Globals tab. Extracted from + * {@link GlobalsTab} so we can unit-test them without a React + * Testing Library dependency — the rest of the tab is JSX + + * useState plumbing on top of the bridge + these helpers. + */ +import type { ReadFailure } from '@/content/window-globals-bridge'; + +/** + * Human-friendly message for a bridge failure. Locked in by tests so + * future tweaks to either side (the bridge reason codes or the + * user-facing copy) stay in lockstep. + * + * The Globals tab uses this both inline (as the collapsed-row + * secondary text) and as the body text when the row is expanded, + * which keeps the failure narrative consistent whether the user + * looks at the summary or expands for details. + */ +export function failureMessage(failure: ReadFailure): string { + switch (failure.reason) { + case 'undefined': + return '(not defined on this page)'; + case 'not-serializable': + return '(value is not JSON-serializable — likely a function or Symbol)'; + case 'serialize-error': + return `(could not serialize: ${failure.message})`; + case 'access-error': + return `(reading the global threw: ${failure.message})`; + case 'bridge-unavailable': + // Catches both the "CSP / Trusted Types blocked the page-bridge + // injection" and "page-bridge timed out" cases. Users don't need + // to distinguish; both render as a single "(could not read)" + // hint with the underlying cause documented in the help text. + return '(could not read from this page — strict CSP or page never responded)'; + } +} + +/** + * One-line shape description for a successfully-parsed value, used + * as the collapsed-row secondary text so the user can scan the tab + * without expanding every card. + * + * array → "array · 7 items" + * object → "object · 14 keys" + * null → "null" + * string/etc. → typeof name (`"string"`, `"number"`, `"boolean"`) + * + * Pluralization is per-shape so the 1-item case isn't grammatically + * jarring ("array · 1 item" not "array · 1 items"). + */ +export function summarizeValue(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) { + return `array · ${value.length} item${value.length === 1 ? '' : 's'}`; + } + if (typeof value === 'object') { + const keys = Object.keys(value as object); + return `object · ${keys.length} key${keys.length === 1 ? '' : 's'}`; + } + return typeof value; +} diff --git a/src/content/panel/store.ts b/src/content/panel/store.ts index bf5d7d8..09c79bc 100644 --- a/src/content/panel/store.ts +++ b/src/content/panel/store.ts @@ -11,7 +11,7 @@ import { DEFAULT_PREFERENCES, HIGHLIGHT_MODE_ORDER } from '@/lib/types'; import { savePreferences } from '@/lib/storage'; import { readPageInfo } from '../page-info'; -export type PanelTab = 'inspect' | 'tree' | 'json' | 'diff' | 'seo' | 'notes'; +export type PanelTab = 'inspect' | 'tree' | 'json' | 'diff' | 'seo' | 'notes' | 'globals'; interface ToastMessage { readonly id: number; diff --git a/src/content/panel/styles.css b/src/content/panel/styles.css index a3bf292..30527e7 100644 --- a/src/content/panel/styles.css +++ b/src/content/panel/styles.css @@ -339,6 +339,21 @@ margin: 0 0 6px; } +/* Section title + trailing action row (e.g. Globals tab's "Refresh + all" button). Inline-flex so the title still wraps naturally if + the panel is narrow, with the action pinned right. */ +.cs-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.cs-section-header .cs-section-title { + margin: 0; +} + .cs-name { font-size: 14px; font-weight: 600; diff --git a/src/content/window-globals-bridge.ts b/src/content/window-globals-bridge.ts new file mode 100644 index 0000000..a7d125a --- /dev/null +++ b/src/content/window-globals-bridge.ts @@ -0,0 +1,315 @@ +/** + * Bridge for reading `window.*` globals out of the host page's main world. + * + * Content scripts run in an *isolated world* — their `window` is distinct + * from the page's. Reading `window.nymGtmPage` directly from a content + * script returns `undefined` even if the page has it. To get at page + * globals we need code running in the page's main world. + * + * Approach: inject a one-time `<script>` element whose `textContent` is + * the bridge code (string-inlined below). The injected script installs + * a persistent message listener on `window` that: + * 1. Listens for `clay-slip:globals:request` postMessages from the + * content script. + * 2. Reads each requested key off `window`, `JSON.stringify`s the + * value, posts a `clay-slip:globals:result` back with the same + * correlation ID. + * + * Why inline-string + persistent listener (vs. `chrome.scripting.executeScript` + * with `world: 'MAIN'`, or a separate web_accessible_resource): + * + * - `world: 'MAIN'` requires Firefox 128+; our `strict_min_version` + * is 121, so it would silently no-op on older Firefox. + * - A separate WAR file means an extra Vite build entry, hashed + * filenames the content script has to resolve via `chrome.runtime.getURL`, + * and divergent handling between Chromium and Firefox WAR semantics. + * For ~30 lines of bridge code, the inline-string version is + * dramatically simpler. + * - Persistent listener (vs. inject-per-call) saves the DOM-mutation + * cost on every Refresh click. Idempotent install means subsequent + * mounts are no-ops. + * + * Risks: + * - Pages with a strict CSP (`script-src 'self'` without `unsafe-inline`) + * reject the injection. We catch the failure on the content-script + * side by timing out the pending request and surface + * `{ ok: false, reason: 'bridge-unavailable' }` so the Globals tab + * can render a clear "(could not read)" message. + * - Pages running a Trusted Types policy may reject `textContent` + * assignment to a `<script>` element. Same fallback applies. + * + * Security: + * - The bridge listener only acts on messages where `event.source === window` + * so other pages / extensions can't trigger reads. + * - The content script only resolves a Promise if the incoming result + * carries a correlation ID we issued. Forged results are ignored. + * - Only the keys the user has configured in Options are ever read — + * we never expose an "arbitrary path" remote API. + */ + +const REQUEST_TYPE = 'clay-slip:globals:request'; +const RESULT_TYPE = 'clay-slip:globals:result'; +const INSTALLED_FLAG = '__claySlipGlobalsBridgeInstalled' as const; + +/** + * The actual page-world code. Kept as a single string so we can inline + * it via `<script>.textContent`. No imports; uses only globals. + * + * Notes on the body: + * - `JSON.stringify` on a function returns `undefined` (not a string). + * We treat that as `{ ok: false, reason: 'not-serializable' }` so + * the panel renders something explicit rather than swallowing it. + * - Property access can throw if `window[key]` is a getter that + * throws (rare but possible — some analytics libs do this on + * transitional state). Wrapped in try/catch. + * - `JSON.stringify` throws on circular structures. Same try/catch + * handles that and surfaces the error message. + */ +const PAGE_BRIDGE_SCRIPT = `(() => { + if (window.${INSTALLED_FLAG}) return; + window.${INSTALLED_FLAG} = true; + + window.addEventListener('message', (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || data.type !== ${JSON.stringify(REQUEST_TYPE)}) return; + const { correlationId, keys } = data; + if (typeof correlationId !== 'string' || !Array.isArray(keys)) return; + + const results = Object.create(null); + for (const key of keys) { + if (typeof key !== 'string') continue; + try { + const value = window[key]; + if (typeof value === 'undefined') { + results[key] = { ok: false, reason: 'undefined' }; + continue; + } + try { + const json = JSON.stringify(value); + if (typeof json === 'undefined') { + // JSON.stringify drops functions / Symbol values silently. + // For a top-level global that IS a function, the result is + // undefined — surface it instead of silent-failing. + results[key] = { ok: false, reason: 'not-serializable' }; + } else { + results[key] = { ok: true, json: json }; + } + } catch (err) { + results[key] = { + ok: false, + reason: 'serialize-error', + message: String((err && err.message) || err) + }; + } + } catch (err) { + results[key] = { + ok: false, + reason: 'access-error', + message: String((err && err.message) || err) + }; + } + } + + window.postMessage({ type: ${JSON.stringify(RESULT_TYPE)}, correlationId: correlationId, results: results }, '*'); + }); +})();`; + +export type ReadFailure = + | { readonly ok: false; readonly reason: 'undefined' } + | { readonly ok: false; readonly reason: 'not-serializable' } + | { readonly ok: false; readonly reason: 'serialize-error'; readonly message: string } + | { readonly ok: false; readonly reason: 'access-error'; readonly message: string } + | { readonly ok: false; readonly reason: 'bridge-unavailable' }; + +export type ReadSuccess = { readonly ok: true; readonly json: string }; + +export type ReadResult = ReadSuccess | ReadFailure; + +/** + * How long we wait for the page-world bridge to respond before + * considering the request lost. Set high enough that even a busy + * page (e.g. mid-React-render) responds in time, but low enough + * that the UI doesn't feel stuck if the bridge truly didn't install + * (strict CSP, Trusted Types policy, etc.). + * + * Reads happen on explicit user action (Refresh click / tab open), + * so the only thing waiting is the panel itself. + */ +const REQUEST_TIMEOUT_MS = 1000; + +/** + * Map of in-flight correlation IDs → resolver functions. Used to route + * incoming postMessages back to the right Promise. Entries are deleted + * on response or timeout so the map stays bounded by concurrency, not + * by total lifetime read count. + */ +const pending = new Map<string, (results: Record<string, ReadResult>) => void>(); + +/** + * Idempotent: install the page bridge on first call, no-op on subsequent + * calls. Returns `true` if the injection succeeded (or was already done), + * `false` if a CSP / Trusted Types policy blocked it. + * + * Also installs the content-script-side listener exactly once. + */ +let bridgeInstalled = false; +let bridgeBlocked = false; +let listenerInstalled = false; + +export function installPageBridge(): boolean { + if (bridgeInstalled) return true; + if (bridgeBlocked) return false; + + installContentSideListenerOnce(); + + try { + const script = document.createElement('script'); + // Assigning `textContent` (vs. innerHTML) avoids HTML parsing — the + // string is treated as inert text until it lands in the DOM and the + // browser executes it as a script. This is the standard recipe for + // running code in the page's main world from a content script. + script.textContent = PAGE_BRIDGE_SCRIPT; + (document.head ?? document.documentElement).appendChild(script); + // Remove the <script> tag once it's executed — the listener it + // installed on `window` lives on regardless. Keeps the DOM tidy and + // means devtools "Sources" doesn't show a phantom script tag. + script.remove(); + bridgeInstalled = true; + return true; + } catch { + // CSP or Trusted Types blocked the injection. Mark blocked so we + // don't retry on every request — the user would have to change + // pages (which reloads the content script and resets this state). + bridgeBlocked = true; + return false; + } +} + +function installContentSideListenerOnce(): void { + if (listenerInstalled) return; + listenerInstalled = true; + window.addEventListener('message', (event: MessageEvent) => { + // Only accept messages from our own page-world bridge. Different- + // origin iframes posting to the top window would have a different + // source; ignore them so a malicious embed can't forge results. + if (event.source !== window) return; + const data = event.data as unknown; + if (!isResultMessage(data)) return; + const resolver = pending.get(data.correlationId); + if (!resolver) return; // Stale / unknown correlation ID — ignore. + pending.delete(data.correlationId); + resolver(data.results); + }); +} + +interface ResultMessage { + readonly type: typeof RESULT_TYPE; + readonly correlationId: string; + readonly results: Record<string, ReadResult>; +} + +function isResultMessage(data: unknown): data is ResultMessage { + if (!data || typeof data !== 'object') return false; + const d = data as Record<string, unknown>; + return ( + d.type === RESULT_TYPE && + typeof d.correlationId === 'string' && + typeof d.results === 'object' && + d.results !== null + ); +} + +/** + * Read the requested global keys from the host page's main world. + * + * Returns a Promise that resolves with a map keyed by the input keys. + * Each value is either `{ ok: true, json }` (where `json` is the + * `JSON.stringify` output — caller can `JSON.parse` if they want the + * structured value back, or render the string directly) or one of the + * `ReadFailure` variants. + * + * Behavior on edge cases: + * + * - Empty `keys` array → resolves immediately with `{}` (no bridge + * work). Lets callers pass `prefs.windowGlobals` unconditionally. + * - Bridge blocked by CSP → resolves with every key set to + * `{ ok: false, reason: 'bridge-unavailable' }`. The Globals tab + * renders a "(could not read)" placeholder per row. + * - Bridge doesn't respond within {@link REQUEST_TIMEOUT_MS} → + * resolves with every key set to `bridge-unavailable`. Same UI + * treatment, so the user sees a clear failure mode instead of + * a spinner stuck forever. + * - Concurrent calls work — correlation IDs route each response to + * the right Promise. If the user spams "Refresh all", the second + * call's results don't get crossed with the first. + */ +export function readGlobals(keys: readonly string[]): Promise<Record<string, ReadResult>> { + if (keys.length === 0) return Promise.resolve({}); + + const installed = installPageBridge(); + if (!installed) { + return Promise.resolve(buildAllUnavailable(keys)); + } + + const correlationId = generateCorrelationId(); + + return new Promise<Record<string, ReadResult>>((resolve) => { + let settled = false; + const settle = (results: Record<string, ReadResult>) => { + if (settled) return; + settled = true; + resolve(results); + }; + + pending.set(correlationId, settle); + + setTimeout(() => { + if (!pending.has(correlationId)) return; + pending.delete(correlationId); + settle(buildAllUnavailable(keys)); + }, REQUEST_TIMEOUT_MS); + + window.postMessage( + { + type: REQUEST_TYPE, + correlationId, + keys: keys.slice(), + }, + '*' + ); + }); +} + +function buildAllUnavailable(keys: readonly string[]): Record<string, ReadResult> { + const out: Record<string, ReadResult> = {}; + for (const key of keys) { + out[key] = { ok: false, reason: 'bridge-unavailable' }; + } + return out; +} + +function generateCorrelationId(): string { + // crypto.randomUUID is available in every Chromium 92+ and Firefox 95+, + // well below our manifest floors. Falling back to a Math.random ID + // would still be unique enough in practice (no security boundary + // attached to these), but the UUID is shorter to type into logs. + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `cs-globals-${crypto.randomUUID()}`; + } + // Defensive fallback for any oddball runtime. + return `cs-globals-${Math.random().toString(36).slice(2)}-${Date.now()}`; +} + +/** + * Test-only escape hatch: reset module-level state between tests so + * each test starts with a fresh bridge / listener / pending map. + * Never exported through the public surface of the module — `_resetForTests` + * is a deliberately marked-private name. + */ +export function _resetForTests(): void { + bridgeInstalled = false; + bridgeBlocked = false; + listenerInstalled = false; + pending.clear(); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f63e99f..cfb0409 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -72,6 +72,16 @@ export interface UserPreferences { readonly enableShortcuts: boolean; readonly maxRecentComponents: number; readonly siteHosts: readonly SiteHostMapping[]; + /** + * User-configured top-level `window.*` global names to surface in the + * Globals panel tab. Stored in their normalized form (no `window.` + * prefix, validated as JS identifiers). Empty by default — users + * opt-in per global on the Options page. + * + * See {@link parseWindowGlobal} / {@link normalizeWindowGlobals} in + * `src/lib/window-globals.ts` for the input contract. + */ + readonly windowGlobals: readonly string[]; } /** @@ -113,6 +123,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { enableShortcuts: true, maxRecentComponents: 20, siteHosts: [], + windowGlobals: [], }; /** Minimal serializable info we keep about a component for recents/annotations. */ diff --git a/src/lib/window-globals.ts b/src/lib/window-globals.ts new file mode 100644 index 0000000..999531d --- /dev/null +++ b/src/lib/window-globals.ts @@ -0,0 +1,103 @@ +/** + * Parse and normalize one user-configured Window Global path. + * + * The Options page lets the user type a global as either of: + * + * nymGtmPage + * window.nymGtmPage + * + * Both forms are accepted; the leading `window.` (if present) is stripped + * once. What remains must be a single JavaScript identifier — the v1 + * design intentionally does NOT support nested paths (`window.foo.bar`) + * or array indices (`window.dataLayer[0]`). If someone needs that later, + * the contract here is the only thing that has to change. + * + * Returns a result object instead of throwing so the Options page can + * surface a per-row validation message inline without try/catch noise. + * + * @example + * parseWindowGlobal('window.nymGtmPage') // { ok: true, key: 'nymGtmPage' } + * parseWindowGlobal(' $foo ') // { ok: true, key: '$foo' } + * parseWindowGlobal('foo.bar') // { ok: false, reason: 'dotted-path' } + * parseWindowGlobal('') // { ok: false, reason: 'empty' } + */ +export type ParseResult = + | { readonly ok: true; readonly key: string } + | { + readonly ok: false; + readonly reason: 'empty' | 'dotted-path' | 'bracketed' | 'invalid-identifier'; + }; + +// Standard JS identifier: starts with letter/_/$ then letters/digits/_/$. +// Deliberately NOT Unicode-aware — analytics globals are ASCII in practice +// and we want to surface typos like a stray non-ASCII space as errors. +const IDENT_RE = /^[$_a-zA-Z][$_a-zA-Z0-9]*$/; + +export function parseWindowGlobal(input: string): ParseResult { + // Defensive trim — users frequently paste with leading/trailing spaces + // when copying from analytics docs or DevTools. + const trimmed = input.trim(); + if (!trimmed) return { ok: false, reason: 'empty' }; + + // Strip a *single* leading `window.` so `window.window.foo` rejects + // (suggests a typo, not the user genuinely wanting `window.foo` + // under a property named `window`). + const withoutWindow = trimmed.startsWith('window.') ? trimmed.slice('window.'.length) : trimmed; + + // After the optional strip, an empty string means the user typed just + // `window.` with nothing after. Treat as the same "empty" error so + // the Options-page hint stays simple. + if (!withoutWindow) return { ok: false, reason: 'empty' }; + + // We classify the failure mode so the Options page can show the most + // helpful hint. Dots and brackets are the two paths someone might + // realistically try if they're thinking in JS expression syntax. + if (withoutWindow.includes('.')) return { ok: false, reason: 'dotted-path' }; + if (withoutWindow.includes('[') || withoutWindow.includes(']')) { + return { ok: false, reason: 'bracketed' }; + } + if (!IDENT_RE.test(withoutWindow)) return { ok: false, reason: 'invalid-identifier' }; + + return { ok: true, key: withoutWindow }; +} + +/** + * Human-friendly explanation of a {@link ParseResult} failure, suitable + * for inline display on the Options page. Returns `null` for successful + * results so callers can use `parseResultMessage(result) ?? ''`-style + * chains without extra branching. + */ +export function parseResultMessage(result: ParseResult): string | null { + if (result.ok) return null; + switch (result.reason) { + case 'empty': + return 'Enter a global name (e.g. nymGtmPage or window.dataLayer).'; + case 'dotted-path': + return 'Nested paths are not supported yet — use the top-level global only.'; + case 'bracketed': + return 'Array indices are not supported — use the top-level global only.'; + case 'invalid-identifier': + return 'Must be a valid JavaScript identifier (letters, digits, _, $).'; + } +} + +/** + * Normalize a list of user-entered global paths into the canonical + * keys-only form, deduped, preserving first-occurrence order. + * + * Used both at save time (to keep `chrome.storage.sync` clean) and at + * read time (so the bridge never sees malformed entries — defense in + * depth in case someone hand-edits storage). + */ +export function normalizeWindowGlobals(input: readonly string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const raw of input) { + const result = parseWindowGlobal(raw); + if (!result.ok) continue; + if (seen.has(result.key)) continue; + seen.add(result.key); + out.push(result.key); + } + return out; +} diff --git a/src/options/Options.tsx b/src/options/Options.tsx index e0c1bf4..9bd2674 100644 --- a/src/options/Options.tsx +++ b/src/options/Options.tsx @@ -16,6 +16,7 @@ import { type SiteHostMapping, type UserPreferences, } from '@/lib/types'; +import { parseResultMessage, parseWindowGlobal } from '@/lib/window-globals'; const PANEL_POSITIONS: Array<{ value: PanelPosition; label: string }> = [ { value: 'bottom-right', label: 'Bottom right (corner)' }, @@ -28,11 +29,15 @@ const PANEL_POSITIONS: Array<{ value: PanelPosition; label: string }> = [ export function Options() { const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFERENCES); + const [prefsLoaded, setPrefsLoaded] = useState(false); const [saved, setSaved] = useState(false); const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { - loadPreferences().then(setPrefs); + loadPreferences().then((p) => { + setPrefs(p); + setPrefsLoaded(true); + }); return () => { if (savedTimer.current) clearTimeout(savedTimer.current); }; @@ -76,6 +81,70 @@ export function Options() { }) ); + // Window globals editor state. We keep the raw string the user is + // typing (`globalsDrafts`) separate from the persisted normalized + // list so the input doesn't fight the user mid-keystroke and so + // the inline validation message can update on every change without + // a debounce. + // + // The persisted list (`prefs.windowGlobals`) only ever contains + // normalized identifiers — never `window.foo` or whitespace. Invalid + // drafts hold their editor slot but contribute nothing to storage, + // so the user can fix a typo without re-typing siblings. + // + // Hydration is deliberately *single-shot* on the first `prefsLoaded` + // transition — re-hydrating on every `prefs` change would clobber + // the row the user is currently typing (each keystroke persists, + // which mutates `prefs`, which would re-fire the effect). Cross- + // window sync isn't wired in this Options page, so single-shot is + // the right contract here. + const [globalsDrafts, setGlobalsDrafts] = useState<string[]>(['']); + + useEffect(() => { + if (!prefsLoaded) return; + // Genuine async hydration from chrome.storage.sync — there's no + // way to derive this synchronously during render. The React 19 + // `set-state-in-effect` rule's preferred "derive during render" + // pattern doesn't apply when the source is asynchronous. + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional async hydration; see comment above. + setGlobalsDrafts(prefs.windowGlobals.length > 0 ? [...prefs.windowGlobals] : ['']); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional single-shot on `prefsLoaded` transition; later prefs changes are user edits we mustn't clobber. + }, [prefsLoaded]); + + const persistGlobals = (drafts: readonly string[]) => { + // Persist only the valid, deduped, normalized keys. The editor + // can hold invalid rows indefinitely without polluting storage. + const seen = new Set<string>(); + const out: string[] = []; + for (const d of drafts) { + const parsed = parseWindowGlobal(d); + if (!parsed.ok) continue; + if (seen.has(parsed.key)) continue; + seen.add(parsed.key); + out.push(parsed.key); + } + update('windowGlobals', out); + }; + + const editGlobalDraft = (index: number, value: string) => { + const next = [...globalsDrafts]; + next[index] = value; + setGlobalsDrafts(next); + persistGlobals(next); + }; + + const removeGlobalDraft = (index: number) => { + const next = globalsDrafts.filter((_, i) => i !== index); + // Always keep at least one row in the editor so the "+ Add" button + // isn't the only way back to data entry on a fresh wipe. + setGlobalsDrafts(next.length > 0 ? next : ['']); + persistGlobals(next); + }; + + const addGlobalDraft = () => { + setGlobalsDrafts([...globalsDrafts, '']); + }; + return ( <div className="options"> <header className="options-header"> @@ -265,6 +334,66 @@ export function Options() { </div> </section> + <section className="options-section"> + <h2>Window globals</h2> + <p className="options-section-help"> + Top-level <code>window.*</code> values you want to inspect on every Clay page. Each entry + gets its own collapsible card in the <strong>Globals</strong> panel tab, rendered as + syntax-highlighted JSON. Useful for analytics payloads (e.g. <code>nymGtmPage</code>,{' '} + <code>dataLayer</code>) or any object/array your site sets on <code>window</code> at boot. + You can type either form — <code>nymGtmPage</code> or <code>window.nymGtmPage</code>{' '} + — the extension normalizes them. Nested paths (<code>foo.bar</code>) and array + indices (<code>dataLayer[0]</code>) aren’t supported yet; only top-level globals. + Functions and symbol values can’t be JSON-serialized, so they show as{' '} + <em>“not serializable”</em>. + </p> + + <div className="options-globals"> + {globalsDrafts.map((draft, index) => { + const parsed = parseWindowGlobal(draft); + // Only show validation noise once the user has typed + // something. An empty row should look "ready" not + // "broken" — the placeholder already invites a value. + const showError = !parsed.ok && draft.trim().length > 0; + const errorMessage = showError ? parseResultMessage(parsed) : null; + return ( + <div key={index} className="options-globals-entry"> + <div className="options-globals-row"> + <input + type="text" + placeholder="nymGtmPage or window.dataLayer" + value={draft} + aria-invalid={showError || undefined} + onChange={(e) => editGlobalDraft(index, e.target.value)} + spellCheck={false} + autoComplete="off" + /> + <button + className="options-remove" + title="Remove global" + aria-label={`Remove global ${draft || index + 1}`} + onClick={() => removeGlobalDraft(index)} + > + ✕ + </button> + </div> + {errorMessage && <p className="options-row-error">{errorMessage}</p>} + </div> + ); + })} + </div> + + <div className="options-row"> + <div className="options-label"> + <span>Add global</span> + <span className="options-help">Append a new row to the editor.</span> + </div> + <button className="options-secondary" onClick={addGlobalDraft}> + + Add + </button> + </div> + </section> + <section className="options-section"> <h2>Workflow</h2> diff --git a/src/options/options.css b/src/options/options.css index 7580990..6e4da48 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -256,3 +256,55 @@ kbd { color: var(--accent); border-color: var(--border); } + +/* Window globals editor — simpler 2-col layout than site-host mappings + (one identifier per row, no env columns). Inline validation error + sits directly under the offending row so the user sees the fix-up + hint without scanning a separate validation summary. */ +.options-globals { + margin: 12px 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.options-globals-entry { + display: flex; + flex-direction: column; + gap: 4px; +} + +.options-globals-row { + display: grid; + grid-template-columns: 1fr 28px; + gap: 6px; + align-items: center; +} + +.options-globals-row input[type='text'] { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font-size: 13px; + width: 100%; + font-family: inherit; +} + +.options-globals-row input[type='text']:focus { + outline: none; + border-color: var(--accent); +} + +/* aria-invalid pulls double duty as a visual cue too. */ +.options-globals-row input[aria-invalid='true'] { + border-color: var(--danger, #d33); +} + +.options-row-error { + margin: 0 32px 0 2px; + font-size: 12px; + line-height: 1.4; + color: var(--danger, #d33); +} diff --git a/tests/content/panel/components/globals-format.test.ts b/tests/content/panel/components/globals-format.test.ts new file mode 100644 index 0000000..2f335e7 --- /dev/null +++ b/tests/content/panel/components/globals-format.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for the pure formatting helpers used by the Globals tab. + * + * These are extracted into their own module so we can lock in the + * user-facing copy without rendering the React component. The tab + * itself is JSX + useState plumbing on top of these helpers + the + * already-tested page-bridge. + */ +import { describe, expect, it } from 'vitest'; +import { failureMessage, summarizeValue } from '@/content/panel/components/globals-format'; + +describe('failureMessage', () => { + // Each reason gets a distinct message: regressions that quietly + // collapse two reasons into one would fail this test. We assert + // distinctness as a set so the suite doesn't have to enumerate + // exact strings (which would force a test edit on every copy tweak). + it('returns a distinct message per reason', () => { + const messages = new Set([ + failureMessage({ ok: false, reason: 'undefined' }), + failureMessage({ ok: false, reason: 'not-serializable' }), + failureMessage({ ok: false, reason: 'serialize-error', message: 'circular' }), + failureMessage({ ok: false, reason: 'access-error', message: 'boom' }), + failureMessage({ ok: false, reason: 'bridge-unavailable' }), + ]); + expect(messages.size).toBe(5); + }); + + it('embeds the underlying error in serialize-error / access-error', () => { + // The error from the page bridge is the most actionable detail — + // if the user sees "could not serialize" without the JS error + // message, they can't tell if it's circular, BigInt, etc. + expect(failureMessage({ ok: false, reason: 'serialize-error', message: 'circular' })).toContain( + 'circular' + ); + expect(failureMessage({ ok: false, reason: 'access-error', message: 'boom' })).toContain( + 'boom' + ); + }); + + it('uses neutral wording for undefined / bridge-unavailable', () => { + // Neither case is actually a bug — the page just doesn't have the + // global, or the bridge couldn't run under a strict CSP. The + // wording shouldn't sound alarming. + const undef = failureMessage({ ok: false, reason: 'undefined' }); + const blocked = failureMessage({ ok: false, reason: 'bridge-unavailable' }); + for (const m of [undef, blocked]) { + expect(m.toLowerCase()).not.toContain('error'); + expect(m.toLowerCase()).not.toContain('fail'); + } + }); +}); + +describe('summarizeValue', () => { + it('formats null explicitly (not as "object · 0 keys")', () => { + // `typeof null === 'object'` would otherwise route null to the + // object branch and read "object · 0 keys" which is confusing. + expect(summarizeValue(null)).toBe('null'); + }); + + it('counts array items with correct pluralization', () => { + expect(summarizeValue([])).toBe('array · 0 items'); + expect(summarizeValue([1])).toBe('array · 1 item'); + expect(summarizeValue([1, 2, 3])).toBe('array · 3 items'); + }); + + it('counts object keys with correct pluralization', () => { + expect(summarizeValue({})).toBe('object · 0 keys'); + expect(summarizeValue({ a: 1 })).toBe('object · 1 key'); + expect(summarizeValue({ a: 1, b: 2 })).toBe('object · 2 keys'); + }); + + it('falls back to typeof for primitives', () => { + // Top-level globals like `window.SOME_STRING = "hi"` are rare but + // valid. They should still show *something* meaningful instead + // of a blank summary. + expect(summarizeValue('hi')).toBe('string'); + expect(summarizeValue(42)).toBe('number'); + expect(summarizeValue(true)).toBe('boolean'); + }); +}); diff --git a/tests/content/window-globals-bridge.test.ts b/tests/content/window-globals-bridge.test.ts new file mode 100644 index 0000000..731f9de --- /dev/null +++ b/tests/content/window-globals-bridge.test.ts @@ -0,0 +1,244 @@ +/** + * Tests for the content-script side of the Window Globals bridge. + * + * We don't exercise the actual page-world script body here (that runs + * inside an injected `<script>` element with a real `window.postMessage` + * round-trip — happy-dom does support that, but stubbing the page-world + * responses gives us deterministic control over every result variant + * without coupling the test to the inlined script's exact wording). + * + * Instead, we drive `window.postMessage` events directly to simulate + * what the bridge script would post back, and assert that the + * content-script side: + * - Routes responses to the right Promise via correlation IDs. + * - Handles concurrent calls without cross-talk. + * - Times out when no response arrives. + * - Resets cleanly across test boundaries. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _resetForTests, + installPageBridge, + readGlobals, + type ReadResult, +} from '@/content/window-globals-bridge'; + +const RESULT_TYPE = 'clay-slip:globals:result'; +const REQUEST_TYPE = 'clay-slip:globals:request'; + +/** + * Build a result-message payload that mirrors what the page-world + * script would post back. The shape is locked in by these tests + * because both ends have to agree on it. + */ +function resultMessage( + correlationId: string, + results: Record<string, ReadResult> +): { type: string; correlationId: string; results: Record<string, ReadResult> } { + return { type: RESULT_TYPE, correlationId, results }; +} + +/** + * Capture the correlation ID + keys from the next outgoing request + * the bridge posts to `window`. Wraps `window.postMessage` with a + * spy so we can fake the response message that should follow. + * + * Returns a Promise that resolves with the first observed request. + * The bridge sends exactly one message per `readGlobals` call. + */ +function captureNextRequest(): Promise<{ correlationId: string; keys: string[] }> { + return new Promise((resolve) => { + const original = window.postMessage; + window.postMessage = ((message: unknown, ...rest: unknown[]) => { + const m = message as { type?: string; correlationId?: string; keys?: string[] }; + if (m && m.type === REQUEST_TYPE && m.correlationId && Array.isArray(m.keys)) { + // Restore before resolving so subsequent posts go through + // the real implementation (including the test's own response + // dispatch via dispatchEvent below). + window.postMessage = original; + resolve({ correlationId: m.correlationId, keys: m.keys }); + return; + } + // Forward anything else (test setup, response dispatches) to + // the real implementation so the bridge's incoming-message + // listener still fires. + original.call(window, message as never, ...(rest as [])); + }) as typeof window.postMessage; + }); +} + +/** + * Dispatch a synthetic MessageEvent at `window` so the bridge's + * incoming listener fires. happy-dom's `postMessage` is queued — we + * use the synchronous `dispatchEvent` path so tests don't have to + * `await` a tick after responding. + */ +function dispatchResponse(payload: object): void { + const event = new MessageEvent('message', { + data: payload, + source: window, + }); + window.dispatchEvent(event); +} + +beforeEach(() => { + _resetForTests(); + // Wipe any script tag a previous test's `installPageBridge` left + // behind. The bridge removes its own script immediately after + // append, but in case a test threw mid-install we play it safe. + for (const s of document.querySelectorAll('script')) s.remove(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('installPageBridge', () => { + it('is idempotent — subsequent calls are no-ops', () => { + // First call sets bridgeInstalled = true and injects the <script>. + // We don't have to assert the injection details (the inlined string + // body is tested at integration time); just that the second call + // doesn't throw or re-trigger the listener install. + expect(installPageBridge()).toBe(true); + expect(installPageBridge()).toBe(true); + expect(installPageBridge()).toBe(true); + }); +}); + +describe('readGlobals', () => { + it('resolves immediately with {} when given an empty key list', async () => { + // Lets callers pass `prefs.windowGlobals` unconditionally without + // branching on .length. Also avoids the side-effect of installing + // the bridge when no work is needed. + const results = await readGlobals([]); + expect(results).toEqual({}); + }); + + it('round-trips a single-key request to a response keyed the same way', async () => { + const requestPromise = captureNextRequest(); + const readPromise = readGlobals(['nymGtmPage']); + + const { correlationId, keys } = await requestPromise; + expect(keys).toEqual(['nymGtmPage']); + + dispatchResponse( + resultMessage(correlationId, { + nymGtmPage: { ok: true, json: '{"page":"home"}' }, + }) + ); + + const results = await readPromise; + expect(results).toEqual({ + nymGtmPage: { ok: true, json: '{"page":"home"}' }, + }); + }); + + it('propagates each failure-reason verbatim from the page bridge', async () => { + // Lock in the result shape contract that the page-world script + // commits to. If a future tweak to either side changes the reason + // codes silently, this test fails loudly. + const requestPromise = captureNextRequest(); + const readPromise = readGlobals(['missing', 'fn', 'circular', 'throws']); + const { correlationId } = await requestPromise; + + dispatchResponse( + resultMessage(correlationId, { + missing: { ok: false, reason: 'undefined' }, + fn: { ok: false, reason: 'not-serializable' }, + circular: { ok: false, reason: 'serialize-error', message: 'circular structure' }, + throws: { ok: false, reason: 'access-error', message: 'boom' }, + }) + ); + + const results = await readPromise; + expect(results.missing).toEqual({ ok: false, reason: 'undefined' }); + expect(results.fn).toEqual({ ok: false, reason: 'not-serializable' }); + expect(results.circular).toEqual({ + ok: false, + reason: 'serialize-error', + message: 'circular structure', + }); + expect(results.throws).toEqual({ ok: false, reason: 'access-error', message: 'boom' }); + }); + + it('routes concurrent calls via correlation IDs without cross-talk', async () => { + // Spam-Refresh-All scenario: two reads in flight, the second response + // arrives first. Each Promise must only see its own results. + const firstRequest = captureNextRequest(); + const firstRead = readGlobals(['a']); + const { correlationId: id1 } = await firstRequest; + + const secondRequest = captureNextRequest(); + const secondRead = readGlobals(['b']); + const { correlationId: id2 } = await secondRequest; + expect(id1).not.toBe(id2); + + // Respond out of order — second-first. + dispatchResponse(resultMessage(id2, { b: { ok: true, json: '"second"' } })); + dispatchResponse(resultMessage(id1, { a: { ok: true, json: '"first"' } })); + + const [r1, r2] = await Promise.all([firstRead, secondRead]); + expect(r1).toEqual({ a: { ok: true, json: '"first"' } }); + expect(r2).toEqual({ b: { ok: true, json: '"second"' } }); + }); + + it('ignores response messages with an unknown correlation ID', async () => { + // Defends against a malicious page (or a stale message from a + // previous read that timed out) injecting forged results into an + // unrelated in-flight call. The bridge silently drops them. + const requestPromise = captureNextRequest(); + const readPromise = readGlobals(['real']); + const { correlationId } = await requestPromise; + + // Forged result with a bogus correlation ID — must NOT resolve + // the real read. + dispatchResponse(resultMessage('forged-id-not-issued', { real: { ok: true, json: '"hi"' } })); + + // Real response with the correct ID — this one resolves the read. + dispatchResponse(resultMessage(correlationId, { real: { ok: true, json: '"correct"' } })); + + const results = await readPromise; + expect(results).toEqual({ real: { ok: true, json: '"correct"' } }); + }); + + it('times out with bridge-unavailable when no response arrives', async () => { + // 1-second timeout on the production path is too long for tests; + // use vitest's fake timers to fast-forward without sleeping. + vi.useFakeTimers(); + const requestPromise = captureNextRequest(); + const readPromise = readGlobals(['stuck']); + await requestPromise; + + // Run all pending timers to trigger the bridge's timeout fallback. + await vi.runAllTimersAsync(); + + const results = await readPromise; + expect(results).toEqual({ stuck: { ok: false, reason: 'bridge-unavailable' } }); + }); + + it('ignores message events from sources other than `window`', async () => { + // Cross-origin iframes posting to the top window have `event.source` + // pointing at the iframe's window, not the top window. We must + // drop those silently so a malicious embed can't inject forged + // results. + const requestPromise = captureNextRequest(); + const readPromise = readGlobals(['guarded']); + const { correlationId } = await requestPromise; + + // Dispatch a forged event with a different `source`. The bridge's + // event.source !== window check should reject it before it ever + // looks at the correlation ID. + const otherSource = {} as MessageEventSource; + const forged = new MessageEvent('message', { + data: resultMessage(correlationId, { guarded: { ok: true, json: '"forged"' } }), + source: otherSource, + }); + window.dispatchEvent(forged); + + // Now send a legitimate response. The bridge should only honor this one. + dispatchResponse(resultMessage(correlationId, { guarded: { ok: true, json: '"legit"' } })); + + const results = await readPromise; + expect(results.guarded).toEqual({ ok: true, json: '"legit"' }); + }); +}); diff --git a/tests/lib/storage.test.ts b/tests/lib/storage.test.ts index e28f851..9491492 100644 --- a/tests/lib/storage.test.ts +++ b/tests/lib/storage.test.ts @@ -66,3 +66,29 @@ describe('savePreferences', () => { }); }); }); + +describe('windowGlobals round-trip', () => { + // Lock in that the new field behaves like every other preference: + // missing → default ([]), present → persisted as-is, partial + // updates don't clobber sibling fields. Catches the most common + // regression after extending UserPreferences (forgetting to add + // the field to DEFAULT_PREFERENCES). + it('defaults windowGlobals to [] when nothing is stored', async () => { + const prefs = await loadPreferences(); + expect(prefs.windowGlobals).toEqual([]); + }); + + it('round-trips an explicit windowGlobals list', async () => { + await savePreferences({ windowGlobals: ['nymGtmPage', 'dataLayer'] }); + const prefs = await loadPreferences(); + expect(prefs.windowGlobals).toEqual(['nymGtmPage', 'dataLayer']); + }); + + it('preserves other fields when only windowGlobals is updated', async () => { + await savePreferences({ theme: 'dark' }); + await savePreferences({ windowGlobals: ['nymGtmPage'] }); + const prefs = await loadPreferences(); + expect(prefs.theme).toBe('dark'); + expect(prefs.windowGlobals).toEqual(['nymGtmPage']); + }); +}); diff --git a/tests/lib/window-globals.test.ts b/tests/lib/window-globals.test.ts new file mode 100644 index 0000000..ade3717 --- /dev/null +++ b/tests/lib/window-globals.test.ts @@ -0,0 +1,149 @@ +/** + * Unit tests for the Window Globals path parser. The parser is the single + * source of truth for what the Options page accepts and what the bridge + * sees — both go through `parseWindowGlobal` / `normalizeWindowGlobals`. + * Locking the contract down here means adding nested-path support later + * is a single-file change with a single test file to update. + */ +import { describe, expect, it } from 'vitest'; +import { + normalizeWindowGlobals, + parseResultMessage, + parseWindowGlobal, +} from '@/lib/window-globals'; + +describe('parseWindowGlobal', () => { + it('accepts a bare identifier', () => { + // The shape we want most users to discover by default — type the + // name, no boilerplate. + expect(parseWindowGlobal('nymGtmPage')).toEqual({ ok: true, key: 'nymGtmPage' }); + }); + + it('accepts the explicit window. prefix and strips it once', () => { + // Match the way the user phrased the request — both forms should + // round-trip to the same canonical key so Options.tsx + the bridge + // never have to special-case the prefix. + expect(parseWindowGlobal('window.nymGtmPage')).toEqual({ ok: true, key: 'nymGtmPage' }); + }); + + it('trims surrounding whitespace before parsing', () => { + // Frequent paste hazard — copying from analytics docs often picks + // up a trailing space or newline. Trim instead of reject so the + // first-time setup feels forgiving. + expect(parseWindowGlobal(' nymGtmPage ')).toEqual({ ok: true, key: 'nymGtmPage' }); + expect(parseWindowGlobal('\twindow.dataLayer\n')).toEqual({ ok: true, key: 'dataLayer' }); + }); + + it('accepts identifiers starting with $ or _', () => { + // Some analytics libs ship globals like `_satellite` (Adobe Analytics) + // or `$gtm` (custom wrappers). Both are valid JS identifiers, both + // must round-trip. + expect(parseWindowGlobal('_satellite')).toEqual({ ok: true, key: '_satellite' }); + expect(parseWindowGlobal('$gtm')).toEqual({ ok: true, key: '$gtm' }); + }); + + it('rejects empty / whitespace-only input', () => { + expect(parseWindowGlobal('')).toEqual({ ok: false, reason: 'empty' }); + expect(parseWindowGlobal(' ')).toEqual({ ok: false, reason: 'empty' }); + }); + + it('rejects an empty `window.` with nothing after', () => { + // `window.` alone should error with the same "empty" code so the + // Options page doesn't need a third "almost there" message — + // the same hint covers both cases. + expect(parseWindowGlobal('window.')).toEqual({ ok: false, reason: 'empty' }); + expect(parseWindowGlobal(' window. ')).toEqual({ ok: false, reason: 'empty' }); + }); + + it('rejects nested dot-paths with a specific reason', () => { + // Dotted-path is the most likely mistake from someone who thinks + // in JS expression syntax. We surface a dedicated reason code so + // the Options page can show a "not supported yet" hint pointing + // at the v1 limitation, rather than a generic "invalid". + expect(parseWindowGlobal('window.foo.bar')).toEqual({ ok: false, reason: 'dotted-path' }); + expect(parseWindowGlobal('foo.bar.baz')).toEqual({ ok: false, reason: 'dotted-path' }); + }); + + it('rejects bracketed access with a specific reason', () => { + // Same idea as dotted-paths — different user intent ("I want + // window.dataLayer[0]"), different inline hint. + expect(parseWindowGlobal('dataLayer[0]')).toEqual({ ok: false, reason: 'bracketed' }); + expect(parseWindowGlobal("window['foo']")).toEqual({ ok: false, reason: 'bracketed' }); + }); + + it('rejects identifiers that start with a digit', () => { + // 1foo isn't a valid JS identifier; surfacing this as + // invalid-identifier keeps the parser honest with the runtime + // behavior (window[key] would still work, but we want users to + // catch typos at config time, not on a "(not defined)" placeholder). + expect(parseWindowGlobal('1foo')).toEqual({ ok: false, reason: 'invalid-identifier' }); + expect(parseWindowGlobal('123')).toEqual({ ok: false, reason: 'invalid-identifier' }); + }); + + it('rejects identifiers containing spaces or hyphens', () => { + // Common analytics-key typos: people sometimes paste `foo bar` or + // `foo-bar`. Neither is a valid identifier; both should error + // before they hit storage. + expect(parseWindowGlobal('foo bar')).toEqual({ ok: false, reason: 'invalid-identifier' }); + expect(parseWindowGlobal('foo-bar')).toEqual({ ok: false, reason: 'invalid-identifier' }); + }); + + it('only strips the first `window.` prefix (rejects double prefix)', () => { + // `window.window.foo` is almost certainly a copy-paste mistake. + // Stripping once leaves `window.foo`, which the dotted-path rule + // then rejects with a clear reason rather than silently succeeding + // on a key the user didn't actually mean. + expect(parseWindowGlobal('window.window.foo')).toEqual({ + ok: false, + reason: 'dotted-path', + }); + }); +}); + +describe('parseResultMessage', () => { + it('returns null for successful results so callers can chain easily', () => { + // Lets Options.tsx do `parseResultMessage(result) ?? ''` for the + // inline-error slot without conditional logic. + expect(parseResultMessage({ ok: true, key: 'foo' })).toBeNull(); + }); + + it('returns a distinct message per failure reason', () => { + // Each reason gets its own hint — we want users to know whether + // they made a typo, hit a v1 limitation, or just need to fill the + // field. Asserting distinctness here prevents a future refactor + // from collapsing two reasons into one accidentally. + const messages = new Set([ + parseResultMessage({ ok: false, reason: 'empty' }), + parseResultMessage({ ok: false, reason: 'dotted-path' }), + parseResultMessage({ ok: false, reason: 'bracketed' }), + parseResultMessage({ ok: false, reason: 'invalid-identifier' }), + ]); + expect(messages.size).toBe(4); + for (const m of messages) { + expect(typeof m).toBe('string'); + expect((m as string).length).toBeGreaterThan(0); + } + }); +}); + +describe('normalizeWindowGlobals', () => { + it('strips invalid entries and dedups, preserving first-occurrence order', () => { + // Mixed input: a bare key, a window-prefixed dup of it, a totally + // bogus entry, and a second valid key. Expected: just the two + // valid keys in input order. + const input = ['nymGtmPage', 'window.nymGtmPage', 'foo.bar', 'dataLayer']; + expect(normalizeWindowGlobals(input)).toEqual(['nymGtmPage', 'dataLayer']); + }); + + it('returns an empty array when nothing is valid', () => { + // Defensive: hand-edited storage with garbage shouldn't surface as + // ghost sections in the Globals tab. + expect(normalizeWindowGlobals(['', ' ', 'window.', 'foo.bar', '1foo'])).toEqual([]); + }); + + it('returns an empty array for empty input', () => { + // The defaults case. UserPreferences.windowGlobals starts as [], + // and we want that to stay [] after a normalize round-trip. + expect(normalizeWindowGlobals([])).toEqual([]); + }); +});