executable-stories-react 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -372,12 +372,29 @@ interface ReportSearchProps {
372
372
  }
373
373
  declare const ReportSearch: react.ForwardRefExoticComponent<ReportSearchProps & react.RefAttributes<HTMLInputElement>>;
374
374
 
375
+ /**
376
+ * View state in the URL, so a filtered report survives a refresh and can be
377
+ * shared as a link.
378
+ *
379
+ * It lives in the HASH, not the query string, because the flagship surface is a
380
+ * single HTML file opened from disk. A `file://` document has an opaque origin,
381
+ * and Chrome refuses `history.pushState`/`replaceState` when the URL argument
382
+ * changes anything but the fragment there. The fragment is the one part every
383
+ * browser lets us rewrite in place.
384
+ *
385
+ * Format: `#<anchor>?<params>` — the anchor half stays exactly what it has
386
+ * always been, a scenario id deep link, so existing permalinks keep working and
387
+ * a link with no filters is unchanged.
388
+ */
389
+ type UrlStatusFilter = "all" | "passed" | "failed" | "skipped" | "pending";
390
+
375
391
  /**
376
392
  * Pure functions for filtering a StoryReport by a search query.
377
393
  * Used by <ReportInteractive>. Kept side-effect-free so it can be unit-tested.
378
394
  */
379
395
 
380
- type StatusFilter = "all" | "passed" | "failed" | "skipped" | "pending";
396
+ /** Defined with the URL codec that has to validate it, so the two can't drift. */
397
+ type StatusFilter = UrlStatusFilter;
381
398
  interface FilterCriteria {
382
399
  query?: string;
383
400
  status?: StatusFilter;
@@ -443,8 +460,13 @@ interface KeyboardShortcutHandlers {
443
460
  declare function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers): void;
444
461
 
445
462
  /**
446
- * Scrolls to a scenario by URL hash on mount and whenever the hash changes.
447
- * No-op in SSR (only runs in useEffect). Honours `prefers-reduced-motion`.
463
+ * Scrolls to a scenario by URL hash on mount and whenever the deep link
464
+ * changes. No-op in SSR (only runs in useEffect). Honours
465
+ * `prefers-reduced-motion`.
466
+ *
467
+ * The fragment also carries filter state (`#<anchor>?q=...`), so only the
468
+ * anchor half is read — and a hashchange that merely rewrote the filters must
469
+ * not yank the page back to the last anchor, hence the last-anchor guard.
448
470
  */
449
471
  declare function useDeepLinkScroll(): void;
450
472
 
@@ -372,12 +372,29 @@ interface ReportSearchProps {
372
372
  }
373
373
  declare const ReportSearch: react.ForwardRefExoticComponent<ReportSearchProps & react.RefAttributes<HTMLInputElement>>;
374
374
 
375
+ /**
376
+ * View state in the URL, so a filtered report survives a refresh and can be
377
+ * shared as a link.
378
+ *
379
+ * It lives in the HASH, not the query string, because the flagship surface is a
380
+ * single HTML file opened from disk. A `file://` document has an opaque origin,
381
+ * and Chrome refuses `history.pushState`/`replaceState` when the URL argument
382
+ * changes anything but the fragment there. The fragment is the one part every
383
+ * browser lets us rewrite in place.
384
+ *
385
+ * Format: `#<anchor>?<params>` — the anchor half stays exactly what it has
386
+ * always been, a scenario id deep link, so existing permalinks keep working and
387
+ * a link with no filters is unchanged.
388
+ */
389
+ type UrlStatusFilter = "all" | "passed" | "failed" | "skipped" | "pending";
390
+
375
391
  /**
376
392
  * Pure functions for filtering a StoryReport by a search query.
377
393
  * Used by <ReportInteractive>. Kept side-effect-free so it can be unit-tested.
378
394
  */
379
395
 
380
- type StatusFilter = "all" | "passed" | "failed" | "skipped" | "pending";
396
+ /** Defined with the URL codec that has to validate it, so the two can't drift. */
397
+ type StatusFilter = UrlStatusFilter;
381
398
  interface FilterCriteria {
382
399
  query?: string;
383
400
  status?: StatusFilter;
@@ -443,8 +460,13 @@ interface KeyboardShortcutHandlers {
443
460
  declare function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers): void;
444
461
 
445
462
  /**
446
- * Scrolls to a scenario by URL hash on mount and whenever the hash changes.
447
- * No-op in SSR (only runs in useEffect). Honours `prefers-reduced-motion`.
463
+ * Scrolls to a scenario by URL hash on mount and whenever the deep link
464
+ * changes. No-op in SSR (only runs in useEffect). Honours
465
+ * `prefers-reduced-motion`.
466
+ *
467
+ * The fragment also carries filter state (`#<anchor>?q=...`), so only the
468
+ * anchor half is read — and a hashchange that merely rewrote the filters must
469
+ * not yank the page back to the last anchor, hence the last-anchor guard.
448
470
  */
449
471
  declare function useDeepLinkScroll(): void;
450
472
 
@@ -2,12 +2,12 @@
2
2
 
3
3
  // src/interactive/ReportInteractive.tsx
4
4
  import {
5
- useCallback as useCallback4,
5
+ useCallback as useCallback5,
6
6
  useDeferredValue,
7
7
  useId as useId2,
8
8
  useMemo as useMemo3,
9
- useRef as useRef3,
10
- useState as useState7
9
+ useRef as useRef5,
10
+ useState as useState8
11
11
  } from "react";
12
12
 
13
13
  // src/result.ts
@@ -973,11 +973,11 @@ function flakinessOf(entries) {
973
973
  if (transitionScore > 0.2 || failureRate > 0.3) return "unstable";
974
974
  return "stable";
975
975
  }
976
- function diffSinceLastRun(history2, scenarios) {
976
+ function diffSinceLastRun(history, scenarios) {
977
977
  let hasPreviousRun = false;
978
978
  const delta = { newlyFailing: [], newlyPassing: [], added: [] };
979
979
  for (const scenario of scenarios) {
980
- const entries = history2[scenario.id];
980
+ const entries = history[scenario.id];
981
981
  if (!entries || entries.length === 0) continue;
982
982
  const last = entries[entries.length - 1];
983
983
  const prev = entries[entries.length - 2];
@@ -1007,8 +1007,8 @@ function dotTitle(e) {
1007
1007
  return parts.join(" \xB7 ");
1008
1008
  }
1009
1009
  function ScenarioRunHistory({ scenarioId }) {
1010
- const history2 = useScenarioHistory();
1011
- const entries = history2?.[scenarioId];
1010
+ const history = useScenarioHistory();
1011
+ const entries = history?.[scenarioId];
1012
1012
  if (!entries || entries.length < 2) return null;
1013
1013
  const recent = entries.slice(-MAX_DOTS);
1014
1014
  const flaky = flakinessOf(recent) === "flaky";
@@ -1975,6 +1975,54 @@ var ReportSearch = forwardRef(
1975
1975
  // src/interactive/ReportFailureBanner.tsx
1976
1976
  import { useCallback as useCallback2 } from "react";
1977
1977
 
1978
+ // src/lib/hash-state.ts
1979
+ var DEFAULT_URL_STATE = {
1980
+ query: "",
1981
+ status: "all",
1982
+ tags: [],
1983
+ detail: "full"
1984
+ };
1985
+ var STATUSES = ["all", "passed", "failed", "skipped", "pending"];
1986
+ function splitHash(hash) {
1987
+ const raw = hash.replace(/^#/, "");
1988
+ const split = raw.indexOf("?");
1989
+ if (split === -1) return { anchor: raw, params: new URLSearchParams() };
1990
+ return { anchor: raw.slice(0, split), params: new URLSearchParams(raw.slice(split + 1)) };
1991
+ }
1992
+ function decodeUrlState(params) {
1993
+ const status = params.get("status");
1994
+ const tags = params.get("tags");
1995
+ return {
1996
+ query: params.get("q") ?? DEFAULT_URL_STATE.query,
1997
+ status: STATUSES.includes(status) ? status : DEFAULT_URL_STATE.status,
1998
+ tags: tags ? tags.split(",").filter(Boolean) : [],
1999
+ detail: params.get("docs") === "0" ? "minimal" : "full"
2000
+ };
2001
+ }
2002
+ function encodeUrlState(state) {
2003
+ const params = new URLSearchParams();
2004
+ if (state.query) params.set("q", state.query);
2005
+ if (state.status !== "all") params.set("status", state.status);
2006
+ if (state.tags.length > 0) params.set("tags", state.tags.join(","));
2007
+ if (state.detail === "minimal") params.set("docs", "0");
2008
+ return params.toString();
2009
+ }
2010
+ function formatHash(anchor, params) {
2011
+ if (params) return `#${anchor}?${params}`;
2012
+ return anchor ? `#${anchor}` : "";
2013
+ }
2014
+ function writeHash(anchor, params) {
2015
+ if (typeof window === "undefined") return;
2016
+ const next = formatHash(anchor, params);
2017
+ if (next === window.location.hash) return;
2018
+ const url = next || window.location.href.split("#")[0];
2019
+ try {
2020
+ window.history.replaceState(window.history.state, "", url);
2021
+ } catch {
2022
+ window.location.hash = next.slice(1);
2023
+ }
2024
+ }
2025
+
1978
2026
  // src/lib/scroll.ts
1979
2027
  function prefersReducedMotion() {
1980
2028
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@@ -1990,8 +2038,8 @@ function scrollToScenarioId(scenarioId, options = {}) {
1990
2038
  behavior: prefersReducedMotion() ? "auto" : "smooth",
1991
2039
  block: "start"
1992
2040
  });
1993
- if (options.updateHash !== false && typeof history !== "undefined") {
1994
- history.replaceState(null, "", `#${scenarioId}`);
2041
+ if (options.updateHash !== false) {
2042
+ writeHash(scenarioId, splitHash(window.location.hash).params.toString());
1995
2043
  }
1996
2044
  }
1997
2045
 
@@ -2093,12 +2141,12 @@ function ScenarioLinks({ scenarios, tone }) {
2093
2141
  ] });
2094
2142
  }
2095
2143
  function ReportLastRunDelta({
2096
- history: history2,
2144
+ history,
2097
2145
  report
2098
2146
  }) {
2099
- if (!history2) return null;
2147
+ if (!history) return null;
2100
2148
  const scenarios = report.features.flatMap((f) => f.scenarios.map((s) => ({ id: s.id, title: s.title })));
2101
- const delta = diffSinceLastRun(history2, scenarios);
2149
+ const delta = diffSinceLastRun(history, scenarios);
2102
2150
  if (!delta) return null;
2103
2151
  const { newlyFailing, newlyPassing, added } = delta;
2104
2152
  if (newlyFailing.length === 0 && newlyPassing.length === 0 && added.length === 0) {
@@ -2282,14 +2330,16 @@ function useKeyboardShortcuts(handlers) {
2282
2330
  }
2283
2331
 
2284
2332
  // src/interactive/use-deep-link-scroll.ts
2285
- import { useEffect as useEffect4 } from "react";
2333
+ import { useEffect as useEffect4, useRef as useRef3 } from "react";
2286
2334
  function useDeepLinkScroll() {
2335
+ const lastAnchor = useRef3(null);
2287
2336
  useEffect4(() => {
2288
2337
  function scrollToHash() {
2289
- const hash = window.location.hash.replace(/^#/, "");
2290
- if (!hash) return;
2338
+ const { anchor } = splitHash(window.location.hash);
2339
+ if (!anchor || anchor === lastAnchor.current) return;
2340
+ lastAnchor.current = anchor;
2291
2341
  requestAnimationFrame(() => {
2292
- scrollToScenarioId(hash, { updateHash: false });
2342
+ scrollToScenarioId(anchor, { updateHash: false });
2293
2343
  });
2294
2344
  }
2295
2345
  scrollToHash();
@@ -2655,6 +2705,34 @@ function useTheme() {
2655
2705
  return { pref, setPref };
2656
2706
  }
2657
2707
 
2708
+ // src/interactive/use-url-state.ts
2709
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef4, useState as useState7 } from "react";
2710
+ var WRITE_DELAY_MS = 250;
2711
+ function useUrlState() {
2712
+ const [state, setState] = useState7(DEFAULT_URL_STATE);
2713
+ const ready = useRef4(false);
2714
+ useEffect7(() => {
2715
+ const read = () => setState(decodeUrlState(splitHash(window.location.hash).params));
2716
+ read();
2717
+ ready.current = true;
2718
+ window.addEventListener("hashchange", read);
2719
+ return () => window.removeEventListener("hashchange", read);
2720
+ }, []);
2721
+ useEffect7(() => {
2722
+ if (!ready.current) return;
2723
+ const timer = setTimeout(
2724
+ () => writeHash(splitHash(window.location.hash).anchor, encodeUrlState(state)),
2725
+ WRITE_DELAY_MS
2726
+ );
2727
+ return () => clearTimeout(timer);
2728
+ }, [state]);
2729
+ const update = useCallback4(
2730
+ (patch) => setState((prev) => ({ ...prev, ...patch })),
2731
+ []
2732
+ );
2733
+ return [state, update];
2734
+ }
2735
+
2658
2736
  // src/interactive/ReportInteractive.tsx
2659
2737
  import { Fragment as Fragment13, jsx as jsx46, jsxs as jsxs30 } from "react/jsx-runtime";
2660
2738
  function ReportInteractive(props) {
@@ -2697,13 +2775,20 @@ function ReportInteractiveView({
2697
2775
  staleAfterDays = 7,
2698
2776
  scenarioHistory
2699
2777
  }) {
2700
- const [query, setQuery] = useState7("");
2701
- const [statusFilter, setStatusFilter] = useState7("all");
2702
- const [activeTags, setActiveTags] = useState7([]);
2703
- const [detail, setDetail] = useState7("full");
2704
- const [helpOpen, setHelpOpen] = useState7(false);
2778
+ const [urlState, setUrlState] = useUrlState();
2779
+ const { query, status: statusFilter, tags: activeTags, detail } = urlState;
2780
+ const setQuery = useCallback5((next) => setUrlState({ query: next }), [setUrlState]);
2781
+ const setStatusFilter = useCallback5(
2782
+ (next) => setUrlState({ status: next }),
2783
+ [setUrlState]
2784
+ );
2785
+ const setDetail = useCallback5(
2786
+ (next) => setUrlState({ detail: next }),
2787
+ [setUrlState]
2788
+ );
2789
+ const [helpOpen, setHelpOpen] = useState8(false);
2705
2790
  const { pref: themePref, setPref: setThemePref } = useTheme();
2706
- const searchRef = useRef3(null);
2791
+ const searchRef = useRef5(null);
2707
2792
  const expandId = useId2();
2708
2793
  const docsId = useId2();
2709
2794
  const deferredQuery = useDeferredValue(query);
@@ -2727,18 +2812,20 @@ function ReportInteractiveView({
2727
2812
  return opts;
2728
2813
  }, [report]);
2729
2814
  const tagOptions = useMemo3(() => allTags(report), [report]);
2730
- const toggleTag = useCallback4(
2731
- (tag) => setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]),
2732
- []
2815
+ const toggleTag = useCallback5(
2816
+ (tag) => setUrlState({
2817
+ tags: activeTags.includes(tag) ? activeTags.filter((t) => t !== tag) : [...activeTags, tag]
2818
+ }),
2819
+ [activeTags, setUrlState]
2733
2820
  );
2734
- const [toast, setToast] = useState7(null);
2735
- const toastTimer = useRef3(null);
2736
- const showToast = useCallback4((msg) => {
2821
+ const [toast, setToast] = useState8(null);
2822
+ const toastTimer = useRef5(null);
2823
+ const showToast = useCallback5((msg) => {
2737
2824
  setToast(msg);
2738
2825
  if (toastTimer.current) clearTimeout(toastTimer.current);
2739
2826
  toastTimer.current = setTimeout(() => setToast(null), 1800);
2740
2827
  }, []);
2741
- const copy = useCallback4(
2828
+ const copy = useCallback5(
2742
2829
  (text, msg) => {
2743
2830
  if (typeof navigator !== "undefined" && navigator.clipboard) {
2744
2831
  navigator.clipboard.writeText(text).then(() => showToast(msg), () => showToast("Copy failed"));
@@ -2754,15 +2841,15 @@ function ReportInteractiveView({
2754
2841
  }),
2755
2842
  [copy]
2756
2843
  );
2757
- const failureIndexRef = useRef3(0);
2844
+ const failureIndexRef = useRef5(0);
2758
2845
  const allCollapsibleIds = useMemo3(
2759
2846
  () => report.features.flatMap((f) => [f.id, ...f.scenarios.map((s) => s.id)]),
2760
2847
  [report]
2761
2848
  );
2762
- const collapse = useCollapseState(useCallback4(() => defaultCollapsedIds(report), [report]));
2763
- const collapseAll = useCallback4(() => collapse.collapseAll(allCollapsibleIds), [collapse, allCollapsibleIds]);
2764
- const [expandedAll, setExpandedAll] = useState7(() => report.summary.failed === 0);
2765
- const setExpanded = useCallback4(
2849
+ const collapse = useCollapseState(useCallback5(() => defaultCollapsedIds(report), [report]));
2850
+ const collapseAll = useCallback5(() => collapse.collapseAll(allCollapsibleIds), [collapse, allCollapsibleIds]);
2851
+ const [expandedAll, setExpandedAll] = useState8(() => report.summary.failed === 0);
2852
+ const setExpanded = useCallback5(
2766
2853
  (expanded) => {
2767
2854
  if (expanded) collapse.expandAll();
2768
2855
  else collapseAll();
@@ -2770,10 +2857,10 @@ function ReportInteractiveView({
2770
2857
  },
2771
2858
  [collapse, collapseAll]
2772
2859
  );
2773
- const focusSearch = useCallback4(() => {
2860
+ const focusSearch = useCallback5(() => {
2774
2861
  searchRef.current?.focus();
2775
2862
  }, []);
2776
- const stepFailure = useCallback4(
2863
+ const stepFailure = useCallback5(
2777
2864
  (direction) => {
2778
2865
  if (failures.length === 0) return;
2779
2866
  failureIndexRef.current = (failureIndexRef.current + direction + failures.length) % failures.length;
@@ -2782,10 +2869,10 @@ function ReportInteractiveView({
2782
2869
  },
2783
2870
  [failures]
2784
2871
  );
2785
- const toggleHelp = useCallback4(() => {
2872
+ const toggleHelp = useCallback5(() => {
2786
2873
  setHelpOpen((v) => !v);
2787
2874
  }, []);
2788
- const escape = useCallback4(() => {
2875
+ const escape = useCallback5(() => {
2789
2876
  if (helpOpen) setHelpOpen(false);
2790
2877
  else if (query !== "") setQuery("");
2791
2878
  }, [helpOpen, query]);
@@ -2925,11 +3012,11 @@ function ReportInteractiveView({
2925
3012
  import { useMemo as useMemo4 } from "react";
2926
3013
 
2927
3014
  // src/interactive/island-renderers.tsx
2928
- import { createElement, useEffect as useEffect8, useState as useState9 } from "react";
3015
+ import { createElement, useEffect as useEffect9, useState as useState10 } from "react";
2929
3016
 
2930
3017
  // src/components/doc/MermaidView.tsx
2931
- import { useEffect as useEffect7, useRef as useRef4, useState as useState8 } from "react";
2932
- import { jsx as jsx47, jsxs as jsxs31 } from "react/jsx-runtime";
3018
+ import { useEffect as useEffect8, useRef as useRef6, useState as useState9 } from "react";
3019
+ import { Fragment as Fragment14, jsx as jsx47, jsxs as jsxs31 } from "react/jsx-runtime";
2933
3020
  function prefersDark(el) {
2934
3021
  const themed = el?.closest("[data-theme]");
2935
3022
  if (themed?.dataset.theme === "dark") return true;
@@ -2937,23 +3024,40 @@ function prefersDark(el) {
2937
3024
  return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-color-scheme: dark)").matches;
2938
3025
  }
2939
3026
  function MermaidView({ entry, load }) {
2940
- const ref = useRef4(null);
2941
- const [svg, setSvg] = useState8(null);
2942
- useEffect7(() => {
3027
+ const ref = useRef6(null);
3028
+ const [svg, setSvg] = useState9(null);
3029
+ const [error, setError] = useState9(null);
3030
+ useEffect8(() => {
2943
3031
  let cancelled = false;
2944
3032
  const draw = async () => {
3033
+ let mermaid;
3034
+ try {
3035
+ mermaid = await load();
3036
+ } catch {
3037
+ if (!cancelled) {
3038
+ setSvg(null);
3039
+ setError(null);
3040
+ }
3041
+ return;
3042
+ }
2945
3043
  try {
2946
- const mermaid = await load();
2947
3044
  mermaid.initialize({
2948
3045
  startOnLoad: false,
2949
3046
  securityLevel: "strict",
2950
3047
  theme: prefersDark(ref.current) ? "dark" : "default"
2951
3048
  });
3049
+ await mermaid.parse(entry.code);
2952
3050
  const id = `es-mermaid-${Math.random().toString(36).slice(2)}`;
2953
3051
  const { svg: rendered } = await mermaid.render(id, entry.code);
2954
- if (!cancelled) setSvg(rendered);
2955
- } catch {
2956
- if (!cancelled) setSvg(null);
3052
+ if (!cancelled) {
3053
+ setSvg(rendered);
3054
+ setError(null);
3055
+ }
3056
+ } catch (err) {
3057
+ if (!cancelled) {
3058
+ setSvg(null);
3059
+ setError(err instanceof Error ? err.message : String(err));
3060
+ }
2957
3061
  }
2958
3062
  };
2959
3063
  void draw();
@@ -2979,7 +3083,21 @@ function MermaidView({ entry, load }) {
2979
3083
  )
2980
3084
  ] });
2981
3085
  }
2982
- return /* @__PURE__ */ jsx47(MermaidSource, { entry, ref });
3086
+ return /* @__PURE__ */ jsxs31(Fragment14, { children: [
3087
+ error ? /* @__PURE__ */ jsxs31(
3088
+ "p",
3089
+ {
3090
+ role: "alert",
3091
+ "data-es-mermaid-error": true,
3092
+ className: "mt-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive",
3093
+ children: [
3094
+ "Diagram failed to render: ",
3095
+ error
3096
+ ]
3097
+ }
3098
+ ) : null,
3099
+ /* @__PURE__ */ jsx47(MermaidSource, { entry, ref })
3100
+ ] });
2983
3101
  }
2984
3102
 
2985
3103
  // src/interactive/island-renderers.tsx
@@ -3000,8 +3118,8 @@ function cachedCdnModule(url) {
3000
3118
  var loadMermaidFromCdn = cachedCdnModule(MERMAID_ESM);
3001
3119
  var loadHljs = cachedCdnModule(HLJS_ESM);
3002
3120
  function CdnHighlightedCode({ entry }) {
3003
- const [html, setHtml] = useState9(null);
3004
- useEffect8(() => {
3121
+ const [html, setHtml] = useState10(null);
3122
+ useEffect9(() => {
3005
3123
  let cancelled = false;
3006
3124
  void (async () => {
3007
3125
  try {