Bug 1552171 - Refactor preview redux. r=davidwalsh
authorJason Laster <jlaster@mozilla.com>
Fri, 17 May 2019 14:54:17 +0000
changeset 474340 35b60d3e1321c66117f647b93fcb95f61a1fb5fe
parent 474339 d51a13b4f0ef895da2f5ba19f3918904c42ae96f
child 474341 65747386d437720e71bf5a57eb637dc56e343dd2
push id36030
push userrgurzau@mozilla.com
push dateFri, 17 May 2019 21:41:01 +0000
treeherdermozilla-central@7c540586aedb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdavidwalsh
bugs1552171
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1552171 - Refactor preview redux. r=davidwalsh Differential Revision: https://phabricator.services.mozilla.com/D31454
devtools/client/debugger/src/actions/preview.js
devtools/client/debugger/src/actions/types/ASTAction.js
devtools/client/debugger/src/actions/types/PreviewAction.js
devtools/client/debugger/src/actions/types/index.js
devtools/client/debugger/src/components/Editor/Preview/Popup.js
devtools/client/debugger/src/components/Editor/Preview/index.js
devtools/client/debugger/src/reducers/ast.js
devtools/client/debugger/src/reducers/index.js
devtools/client/debugger/src/reducers/moz.build
devtools/client/debugger/src/reducers/preview.js
devtools/client/debugger/src/reducers/types.js
devtools/client/debugger/src/selectors/index.js
devtools/client/debugger/test/mochitest/helpers.js
--- a/devtools/client/debugger/src/actions/preview.js
+++ b/devtools/client/debugger/src/actions/preview.js
@@ -1,17 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import { isConsole } from "../utils/preview";
 import { findBestMatchExpression } from "../utils/ast";
-import { PROMISE } from "./utils/middleware/promise";
 import { getExpressionFromCoords } from "../utils/editor/get-expression";
 import { isOriginal } from "../utils/source";
 import { isTesting } from "devtools-environment";
 
 import {
   getPreview,
   isLineInScope,
   isSelectedFrameVisible,
@@ -79,98 +78,96 @@ export function setPreview(
   cx: Context,
   expression: string,
   location: AstLocation,
   tokenPos: Position,
   cursorPos: ClientRect,
   target: HTMLElement
 ) {
   return async ({ dispatch, getState, client, sourceMaps }: ThunkArgs) => {
-    await dispatch({
+    if (getPreview(getState())) {
+      dispatch(clearPreview(cx));
+    }
+
+    const source = getSelectedSource(getState());
+    if (!source) {
+      return;
+    }
+
+    const thread = getCurrentThread(getState());
+    const selectedFrame = getSelectedFrame(getState(), thread);
+
+    if (location && isOriginal(source)) {
+      const mapResult = await dispatch(getMappedExpression(expression));
+      if (mapResult) {
+        expression = mapResult.expression;
+      }
+    }
+
+    if (!selectedFrame) {
+      return;
+    }
+
+    const { result } = await client.evaluateInFrame(expression, {
+      frameId: selectedFrame.id,
+      thread
+    });
+
+    // Error case occurs for a token that follows an errored evaluation
+    // https://github.com/firefox-devtools/debugger/pull/8056
+    // Accommodating for null allows us to show preview for falsy values
+    // line "", false, null, Nan, and more
+    if (result === null) {
+      return;
+    }
+
+    // Handle cases where the result is invisible to the debugger
+    // and not possible to preview. Bug 1548256
+    if (result.class && result.class.includes("InvisibleToDebugger")) {
+      return;
+    }
+
+    const root = {
+      name: expression,
+      path: expression,
+      contents: { value: result }
+    };
+    const properties = await client.loadObjectProperties(root);
+
+    // The first time a popup is rendered, the mouse should be hovered
+    // on the token. If it happens to be hovered on whitespace, it should
+    // not render anything
+    if (!target.matches(":hover") && !isTesting()) {
+      return;
+    }
+
+    dispatch({
       type: "SET_PREVIEW",
       cx,
-      [PROMISE]: (async function() {
-        if (getPreview(getState())) {
-          dispatch(clearPreview(cx));
-        }
-
-        const source = getSelectedSource(getState());
-        if (!source) {
-          return;
-        }
-
-        const thread = getCurrentThread(getState());
-        const selectedFrame = getSelectedFrame(getState(), thread);
-
-        if (location && isOriginal(source)) {
-          const mapResult = await dispatch(getMappedExpression(expression));
-          if (mapResult) {
-            expression = mapResult.expression;
-          }
-        }
-
-        if (!selectedFrame) {
-          return;
-        }
-
-        const { result } = await client.evaluateInFrame(expression, {
-          frameId: selectedFrame.id,
-          thread
-        });
-
-        // Error case occurs for a token that follows an errored evaluation
-        // https://github.com/firefox-devtools/debugger/pull/8056
-        // Accommodating for null allows us to show preview for falsy values
-        // line "", false, null, Nan, and more
-        if (result === null) {
-          return;
-        }
-
-        // Handle cases where the result is invisible to the debugger
-        // and not possible to preview. Bug 1548256
-        if (result.class && result.class.includes("InvisibleToDebugger")) {
-          return;
-        }
-
-        const root = {
-          name: expression,
-          path: expression,
-          contents: { value: result }
-        };
-        const properties = await client.loadObjectProperties(root);
-
-        // The first time a popup is rendered, the mouse should be hovered
-        // on the token. If it happens to be hovered on whitespace, it should
-        // not render anything
-        if (!target.matches(":hover") && !isTesting()) {
-          return;
-        }
-
-        return {
-          expression,
-          result,
-          properties,
-          root,
-          location,
-          tokenPos,
-          cursorPos,
-          target
-        };
-      })()
+      value: {
+        expression,
+        result,
+        properties,
+        root,
+        location,
+        tokenPos,
+        cursorPos,
+        target
+      }
     });
   };
 }
 
 export function clearPreview(cx: Context) {
   return ({ dispatch, getState, client }: ThunkArgs) => {
     const currentSelection = getPreview(getState());
     if (!currentSelection) {
       return;
     }
 
     return dispatch(
       ({
-        type: "CLEAR_SELECTION",
+        type: "CLEAR_PREVIEW",
         cx
       }: Action)
     );
   };
 }
--- a/devtools/client/debugger/src/actions/types/ASTAction.js
+++ b/devtools/client/debugger/src/actions/types/ASTAction.js
@@ -2,17 +2,16 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { SymbolDeclarations, AstLocation } from "../../workers/parser";
 import type { PromiseAction } from "../utils/middleware/promise";
 import type { Context } from "../../types";
-import type { PreviewValue } from "../../reducers/types";
 
 export type ASTAction =
   | PromiseAction<
       {|
         +type: "SET_SYMBOLS",
         +cx: Context,
         +sourceId: string
       |},
@@ -22,20 +21,9 @@ export type ASTAction =
       +type: "OUT_OF_SCOPE_LOCATIONS",
       +cx: Context,
       +locations: ?(AstLocation[])
     |}
   | {|
       +type: "IN_SCOPE_LINES",
       +cx: Context,
       +lines: number[]
-    |}
-  | PromiseAction<
-      {|
-        +type: "SET_PREVIEW",
-        +cx: Context
-      |},
-      PreviewValue
-    >
-  | {|
-      +type: "CLEAR_SELECTION",
-      +cx: Context
     |};
new file mode 100644
--- /dev/null
+++ b/devtools/client/debugger/src/actions/types/PreviewAction.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { Context } from "../../types";
+import type { Preview } from "../../reducers/types";
+
+export type PreviewAction =
+  | {|
+      +type: "SET_PREVIEW",
+      +cx: Context,
+      value: Preview
+    |}
+  | {|
+      +type: "CLEAR_PREVIEW",
+      +cx: Context
+    |};
--- a/devtools/client/debugger/src/actions/types/index.js
+++ b/devtools/client/debugger/src/actions/types/index.js
@@ -11,16 +11,17 @@ import type { MatchedLocations } from ".
 import type { TreeNode } from "../../utils/sources-tree/types";
 import type { SearchOperation } from "../../reducers/project-text-search";
 
 import type { BreakpointAction } from "./BreakpointAction";
 import type { SourceAction } from "./SourceAction";
 import type { SourceActorAction } from "./SourceActorAction";
 import type { UIAction } from "./UIAction";
 import type { PauseAction } from "./PauseAction";
+import type { PreviewAction } from "./PreviewAction";
 import type { ASTAction } from "./ASTAction";
 import { clientCommands } from "../../client/firefox";
 import type { Panel } from "../../client/firefox/types";
 import type { ParserDispatcher } from "../../workers/parser";
 
 /**
  * Flow types
  * @module actions/types
@@ -153,31 +154,30 @@ export type DebuggeeAction =
 export type {
   StartPromiseAction,
   DonePromiseAction,
   ErrorPromiseAction
 } from "../utils/middleware/promise";
 
 export type { panelPositionType } from "./UIAction";
 
-export type { ASTAction } from "./ASTAction";
-
 /**
  * Actions: Source, Breakpoint, and Navigation
  *
  * @memberof actions/types
  * @static
  */
 export type Action =
   | AddTabAction
   | UpdateTabAction
   | SourceActorAction
   | SourceAction
   | BreakpointAction
   | PauseAction
   | NavigateAction
   | UIAction
   | ASTAction
+  | PreviewAction
   | QuickOpenAction
   | FileTextSearchAction
   | ProjectTextSearchAction
   | DebuggeeAction
   | SourceTreeAction;
--- a/devtools/client/debugger/src/components/Editor/Preview/Popup.js
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
@@ -26,21 +26,21 @@ import Popover from "../../shared/Popove
 import PreviewFunction from "../../shared/PreviewFunction";
 
 import { createObjectClient } from "../../../client/firefox";
 
 import "./Popup.css";
 
 import type { Coords } from "../../shared/Popover";
 import type { ThreadContext } from "../../../types";
-import type { PreviewValue } from "../../../reducers/types";
+import type { Preview } from "../../../reducers/types";
 
 type Props = {
   cx: ThreadContext,
-  preview: PreviewValue,
+  preview: Preview,
   editor: any,
   editorRef: ?HTMLDivElement,
   addExpression: typeof actions.addExpression,
   selectSourceURL: typeof actions.selectSourceURL,
   openLink: typeof actions.openLink,
   openElementInInspector: typeof actions.openElementInInspectorCommand,
   highlightDomElement: typeof actions.highlightDomElement,
   unHighlightDomElement: typeof actions.unHighlightDomElement,
--- a/devtools/client/debugger/src/components/Editor/Preview/index.js
+++ b/devtools/client/debugger/src/components/Editor/Preview/index.js
@@ -9,23 +9,23 @@ import { connect } from "../../../utils/
 
 import Popup from "./Popup";
 
 import { getPreview, getThreadContext } from "../../../selectors";
 import actions from "../../../actions";
 
 import type { ThreadContext } from "../../../types";
 
-import type { Preview as PreviewType } from "../../../reducers/ast";
+import type { Preview as PreviewType } from "../../../reducers/types";
 
 type Props = {
   cx: ThreadContext,
   editor: any,
   editorRef: ?HTMLDivElement,
-  preview: PreviewType,
+  preview: ?PreviewType,
   clearPreview: typeof actions.clearPreview,
   addExpression: typeof actions.addExpression,
   updatePreview: typeof actions.updatePreview
 };
 
 type State = {
   selecting: boolean
 };
@@ -73,26 +73,22 @@ class Preview extends PureComponent<Prop
     codeMirror.on("scroll", this.onScroll);
     codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp);
     codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown);
   }
 
   updateHighlight(prevProps) {
     const { preview } = this.props;
 
-    if (preview && !preview.updating && preview.target.matches(":hover")) {
+    if (preview && preview.target.matches(":hover")) {
       const target = getElementFromPos(preview.cursorPos);
       target && target.classList.add("preview-selection");
     }
 
-    if (
-      prevProps.preview &&
-      !prevProps.preview.updating &&
-      prevProps.preview !== preview
-    ) {
+    if (prevProps.preview && prevProps.preview !== preview) {
       const target = getElementFromPos(prevProps.preview.cursorPos);
       target && target.classList.remove("preview-selection");
     }
   }
 
   onTokenEnter = ({ target, tokenPos }) => {
     const { cx, editor, updatePreview, preview } = this.props;
 
@@ -118,17 +114,17 @@ class Preview extends PureComponent<Prop
     if (this.props.cx.isPaused) {
       this.setState({ selecting: true });
       return true;
     }
   };
 
   render() {
     const { preview } = this.props;
-    if (!preview || preview.updating || this.state.selecting) {
+    if (!preview || this.state.selecting) {
       return null;
     }
 
     return (
       <Popup
         preview={preview}
         editor={this.props.editor}
         editorRef={this.props.editorRef}
--- a/devtools/client/debugger/src/reducers/ast.js
+++ b/devtools/client/debugger/src/reducers/ast.js
@@ -8,59 +8,42 @@
  * Ast reducer
  * @module reducers/ast
  */
 
 import type { AstLocation, SymbolDeclarations } from "../workers/parser";
 
 import type { Source } from "../types";
 import type { Action, DonePromiseAction } from "../actions/types";
-import type { Node, Grip, GripProperties } from "devtools-reps";
 
 type EmptyLinesType = number[];
 
 export type LoadedSymbols = SymbolDeclarations;
 export type Symbols = LoadedSymbols | {| loading: true |};
 
 export type EmptyLinesMap = { [k: string]: EmptyLinesType };
 export type SymbolsMap = { [k: string]: Symbols };
 
 export type SourceMetaDataType = {
   framework: ?string
 };
 
 export type SourceMetaDataMap = { [k: string]: SourceMetaDataType };
 
-export type Preview = {| updating: true |} | null | PreviewValue;
-
-export type PreviewValue = {|
-  expression: string,
-  result: Grip,
-  root: Node,
-  properties: GripProperties,
-  location: AstLocation,
-  cursorPos: any,
-  tokenPos: AstLocation,
-  updating: false,
-  target: HTMLDivElement
-|};
-
 export type ASTState = {
   +symbols: SymbolsMap,
   +outOfScopeLocations: ?Array<AstLocation>,
-  +inScopeLines: ?Array<number>,
-  +preview: Preview
+  +inScopeLines: ?Array<number>
 };
 
 export function initialASTState(): ASTState {
   return {
     symbols: {},
     outOfScopeLocations: null,
-    inScopeLines: null,
-    preview: null
+    inScopeLines: null
   };
 }
 
 function update(state: ASTState = initialASTState(), action: Action): ASTState {
   switch (action.type) {
     case "SET_SYMBOLS": {
       const { sourceId } = action;
       if (action.status === "start") {
@@ -80,37 +63,16 @@ function update(state: ASTState = initia
     case "OUT_OF_SCOPE_LOCATIONS": {
       return { ...state, outOfScopeLocations: action.locations };
     }
 
     case "IN_SCOPE_LINES": {
       return { ...state, inScopeLines: action.lines };
     }
 
-    case "CLEAR_SELECTION": {
-      return { ...state, preview: null };
-    }
-
-    case "SET_PREVIEW": {
-      if (action.status == "start") {
-        return { ...state, preview: { updating: true } };
-      }
-
-      if (!action.value) {
-        return { ...state, preview: null };
-      }
-
-      // NOTE: if the preview does not exist, it has been cleared
-      if (state.preview) {
-        return { ...state, preview: { ...action.value, updating: false } };
-      }
-
-      return state;
-    }
-
     case "RESUME": {
       return { ...state, outOfScopeLocations: null };
     }
 
     case "NAVIGATE": {
       return initialASTState();
     }
 
@@ -157,20 +119,16 @@ export function isSymbolsLoading(state: 
 
   return symbols.loading;
 }
 
 export function getOutOfScopeLocations(state: OuterState) {
   return state.ast.outOfScopeLocations;
 }
 
-export function getPreview(state: OuterState) {
-  return state.ast.preview;
-}
-
 export function getInScopeLines(state: OuterState) {
   return state.ast.inScopeLines;
 }
 
 export function isLineInScope(state: OuterState, line: number) {
   const linesInScope = state.ast.inScopeLines;
   return linesInScope && linesInScope.includes(line);
 }
--- a/devtools/client/debugger/src/reducers/index.js
+++ b/devtools/client/debugger/src/reducers/index.js
@@ -15,16 +15,17 @@ import sources from "./sources";
 import tabs from "./tabs";
 import breakpoints from "./breakpoints";
 import pendingBreakpoints from "./pending-breakpoints";
 import asyncRequests from "./async-requests";
 import pause from "./pause";
 import ui from "./ui";
 import fileSearch from "./file-search";
 import ast from "./ast";
+import preview from "./preview";
 import projectTextSearch from "./project-text-search";
 import quickOpen from "./quick-open";
 import sourceTree from "./source-tree";
 import debuggee from "./debuggee";
 import eventListenerBreakpoints from "./event-listeners";
 
 // eslint-disable-next-line import/named
 import { objectInspector } from "devtools-reps";
@@ -41,10 +42,11 @@ export default {
   ui,
   fileSearch,
   ast,
   projectTextSearch,
   quickOpen,
   sourceTree,
   debuggee,
   objectInspector: objectInspector.reducer.default,
-  eventListenerBreakpoints
+  eventListenerBreakpoints,
+  preview
 };
--- a/devtools/client/debugger/src/reducers/moz.build
+++ b/devtools/client/debugger/src/reducers/moz.build
@@ -13,16 +13,17 @@ CompiledModules(
     'breakpoints.js',
     'debuggee.js',
     'event-listeners.js',
     'expressions.js',
     'file-search.js',
     'index.js',
     'pause.js',
     'pending-breakpoints.js',
+    'preview.js',
     'project-text-search.js',
     'quick-open.js',
     'source-actors.js',
     'source-tree.js',
     'sources.js',
     'tabs.js',
     'ui.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/preview.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { AstLocation } from "../workers/parser";
+
+import type { Action } from "../actions/types";
+import type { Node, Grip, GripProperties } from "devtools-reps";
+
+export type Preview = {|
+  expression: string,
+  result: Grip,
+  root: Node,
+  properties: GripProperties,
+  location: AstLocation,
+  cursorPos: any,
+  tokenPos: AstLocation,
+  target: HTMLDivElement
+|};
+
+export type PreviewState = {
+  +preview: ?Preview
+};
+
+export function initialPreviewState(): PreviewState {
+  return {
+    preview: null
+  };
+}
+
+function update(
+  state: PreviewState = initialPreviewState(),
+  action: Action
+): PreviewState {
+  switch (action.type) {
+    case "CLEAR_PREVIEW": {
+      return { ...state, preview: null };
+    }
+
+    case "SET_PREVIEW": {
+      return { ...state, preview: action.value };
+    }
+  }
+
+  return state;
+}
+
+// NOTE: we'd like to have the app state fully typed
+// https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js#L179-L185
+type OuterState = { preview: PreviewState };
+
+export function getPreview(state: OuterState) {
+  return state.preview.preview;
+}
+
+export default update;
--- a/devtools/client/debugger/src/reducers/types.js
+++ b/devtools/client/debugger/src/reducers/types.js
@@ -10,32 +10,34 @@
 // @flow
 
 import type { ASTState } from "./ast";
 import type { BreakpointsState } from "./breakpoints";
 import type { ExpressionState } from "./expressions";
 import type { DebuggeeState } from "./debuggee";
 import type { FileSearchState } from "./file-search";
 import type { PauseState } from "./pause";
+import type { PreviewState } from "./preview";
 import type { PendingBreakpointsState } from "../selectors";
 import type { ProjectTextSearchState } from "./project-text-search";
 import type { Record } from "../utils/makeRecord";
 import type { SourcesState } from "./sources";
 import type { SourceActorsState } from "./source-actors";
 import type { TabList } from "./tabs";
 import type { UIState } from "./ui";
 import type { QuickOpenState } from "./quick-open";
 
 export type State = {
   ast: ASTState,
   breakpoints: BreakpointsState,
   expressions: Record<ExpressionState>,
   debuggee: DebuggeeState,
   fileSearch: Record<FileSearchState>,
   pause: PauseState,
+  preview: PreviewState,
   pendingBreakpoints: PendingBreakpointsState,
   projectTextSearch: ProjectTextSearchState,
   sources: SourcesState,
   sourceActors: SourceActorsState,
   tabs: TabList,
   ui: Record<UIState>,
   quickOpen: Record<QuickOpenState>
 };
@@ -51,9 +53,10 @@ export type PendingSelectedLocation = {
 export type {
   SourcesMap,
   SourcesMapByThread,
   SourceResourceState
 } from "./sources";
 export type { ActiveSearchType, OrientationType } from "./ui";
 export type { BreakpointsMap, XHRBreakpointsList } from "./breakpoints";
 export type { Command } from "./pause";
-export type { LoadedSymbols, Symbols, Preview, PreviewValue } from "./ast";
+export type { LoadedSymbols, Symbols } from "./ast";
+export type { Preview } from "./preview";
--- a/devtools/client/debugger/src/selectors/index.js
+++ b/devtools/client/debugger/src/selectors/index.js
@@ -12,16 +12,17 @@ export * from "../reducers/pause";
 export * from "../reducers/debuggee";
 export * from "../reducers/breakpoints";
 export * from "../reducers/pending-breakpoints";
 export * from "../reducers/ui";
 export * from "../reducers/file-search";
 export * from "../reducers/ast";
 export * from "../reducers/project-text-search";
 export * from "../reducers/source-tree";
+export * from "../reducers/preview";
 
 export {
   getSourceActor,
   hasSourceActor,
   getSourceActors,
   getSourceActorsForThread
 } from "../reducers/source-actors";
 
--- a/devtools/client/debugger/test/mochitest/helpers.js
+++ b/devtools/client/debugger/test/mochitest/helpers.js
@@ -1548,28 +1548,26 @@ function tryHovering(dbg, line, column, 
 }
 
 async function assertPreviewTextValue(dbg, line, column, { text, expression }) {
   const previewEl = await tryHovering(dbg, line, column, "previewPopup");
 
   ok(previewEl.innerText.includes(text), "Preview text shown to user");
 
   const preview = dbg.selectors.getPreview();
-  is(preview.updating, false, "Preview.updating");
   is(preview.expression, expression, "Preview.expression");
 }
 
 async function assertPreviewTooltip(dbg, line, column, { result, expression }) {
   const previewEl = await tryHovering(dbg, line, column, "tooltip");
 
   is(previewEl.innerText, result, "Preview text shown to user");
 
   const preview = dbg.selectors.getPreview();
   is(`${preview.result}`, result, "Preview.result");
-  is(preview.updating, false, "Preview.updating");
   is(preview.expression, expression, "Preview.expression");
 }
 
 async function hoverOnToken(dbg, line, column, selector) {
   await tryHovering(dbg, line, column, selector);
   return dbg.selectors.getPreview();
 }
 
@@ -1584,17 +1582,16 @@ async function assertPreviewPopup(
   dbg,
   line,
   column,
   { field, value, expression }
 ) {
   const preview = await hoverOnToken(dbg, line, column, "popup");
   is(`${getPreviewProperty(preview, field)}`, value, "Preview.result");
 
-  is(preview.updating, false, "Preview.updating");
   is(preview.expression, expression, "Preview.expression");
 }
 
 async function assertPreviews(dbg, previews) {
   for (const { line, column, expression, result, fields } of previews) {
     if (fields && result) {
       throw new Error("Invalid test fixture");
     }