From 67ad2c2bf427e13ab88e1cf0f307fecdb7ca2e03 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 10 Jul 2023 12:56:56 -0700 Subject: [PATCH] feat(ui): render all console / network messages in trace (#24115) --- packages/recorder/src/callLog.css | 4 +- packages/trace-viewer/src/ui/actionList.css | 4 +- .../trace-viewer/src/ui/attachmentsTab.tsx | 16 ++- packages/trace-viewer/src/ui/callTab.css | 4 +- packages/trace-viewer/src/ui/consoleTab.css | 28 +---- packages/trace-viewer/src/ui/consoleTab.tsx | 103 +++++++++++------- packages/trace-viewer/src/ui/modelUtil.ts | 4 +- .../src/ui/networkResourceDetails.css | 8 +- .../src/ui/networkResourceDetails.tsx | 11 +- packages/trace-viewer/src/ui/networkTab.tsx | 14 ++- packages/trace-viewer/src/ui/snapshotTab.css | 2 +- packages/trace-viewer/src/ui/timeline.css | 8 +- packages/trace-viewer/src/ui/workbench.tsx | 15 +-- packages/web/src/common.css | 23 +--- packages/web/src/components/listView.css | 6 +- packages/web/src/components/listView.tsx | 53 +++++---- packages/web/src/components/tabbedPane.css | 7 -- packages/web/src/components/tabbedPane.tsx | 6 +- tests/assets/frames/frame.html | 1 + tests/library/trace-viewer.spec.ts | 32 +++++- tests/page/page-check.spec.ts | 22 ++++ 21 files changed, 205 insertions(+), 166 deletions(-) diff --git a/packages/recorder/src/callLog.css b/packages/recorder/src/callLog.css index 4f6da99844..e519217f96 100644 --- a/packages/recorder/src/callLog.css +++ b/packages/recorder/src/callLog.css @@ -73,11 +73,11 @@ } .call-log-url { - color: var(--blue); + color: var(--vscode-charts-blue); } .call-log-selector { - color: var(--orange); + color: var(--vscode-charts-orange); white-space: nowrap; } diff --git a/packages/trace-viewer/src/ui/actionList.css b/packages/trace-viewer/src/ui/actionList.css index d361d52447..ce94057bca 100644 --- a/packages/trace-viewer/src/ui/actionList.css +++ b/packages/trace-viewer/src/ui/actionList.css @@ -62,12 +62,12 @@ display: inline; flex: none; padding-left: 5px; - color: var(--orange); + color: var(--vscode-charts-orange); } .action-url { display: inline; flex: none; padding-left: 5px; - color: var(--blue); + color: var(--vscode-charts-blue); } diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index eba678cbf1..7601fb0252 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -18,9 +18,19 @@ import * as React from 'react'; import './attachmentsTab.css'; import { ImageDiffView } from '@web/components/imageDiffView'; import type { TestAttachment } from '@web/components/imageDiffView'; -import type { ActionTraceEventInContext } from './modelUtil'; +import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; export const AttachmentsTab: React.FunctionComponent<{ + model: MultiTraceModel | undefined, +}> = ({ model }) => { + if (!model) + return null; + return
+ { model.actions.map((action, index) => ) } +
; +}; + +export const AttachmentsSection: React.FunctionComponent<{ action: ActionTraceEventInContext | undefined, }> = ({ action }) => { if (!action) @@ -34,7 +44,7 @@ export const AttachmentsTab: React.FunctionComponent<{ const traceUrl = action.context.traceUrl; - return
+ return <> {expected && actual &&
Image diff
} {expected && actual && {a.name}
; })} - ; + ; }; function attachmentURL(traceUrl: string, attachment: { diff --git a/packages/trace-viewer/src/ui/callTab.css b/packages/trace-viewer/src/ui/callTab.css index a0f9f2a116..00aa1fe3bb 100644 --- a/packages/trace-viewer/src/ui/callTab.css +++ b/packages/trace-viewer/src/ui/callTab.css @@ -81,7 +81,7 @@ .call-value.datetime, .call-value.string, .call-value.locator { - color: var(--orange); + color: var(--vscode-charts-orange); } .call-value.number, @@ -91,7 +91,7 @@ .call-value.undefined, .call-value.function, .call-value.object { - color: var(--blue); + color: var(--vscode-charts-blue); } .call-tab .error-message { diff --git a/packages/trace-viewer/src/ui/consoleTab.css b/packages/trace-viewer/src/ui/consoleTab.css index 156ca76d86..d9e57b3e44 100644 --- a/packages/trace-viewer/src/ui/consoleTab.css +++ b/packages/trace-viewer/src/ui/consoleTab.css @@ -16,40 +16,21 @@ .console-tab { + display: flex; flex: auto; - line-height: 16px; white-space: pre; - overflow: auto; - padding-top: 3px; user-select: text; } .console-line { - flex: none; - padding: 3px 0 3px 3px; - align-items: center; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; -} - -.console-line.error { - background: var(--vscode-inputValidation-errorBackground); - border-top-color: var(--vscode-inputValidation-errorBorder); - border-bottom-color: var(--vscode-inputValidation-errorBorder); - color: var(--vscode-errorForeground); -} - -.console-line.warning { - background: var(--vscode-inputValidation-warningBackground); - border-top-color: var(--vscode-inputValidation-warningBorder); - border-bottom-color: var(--vscode-inputValidation-warningBorder); + width: 100%; } .console-line .codicon { padding: 0 2px 0 3px; position: relative; flex: none; - top: 1px; + top: 3px; } .console-line.warning .codicon { @@ -57,11 +38,10 @@ } .console-line-message { - white-space: initial; word-break: break-word; white-space: pre-wrap; position: relative; - top: -2px; + user-select: text; } .console-location { diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index cb9470e63f..ed97674c6f 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -19,58 +19,81 @@ import type { ActionTraceEvent } from '@trace/trace'; import * as React from 'react'; import './consoleTab.css'; import * as modelUtil from './modelUtil'; +import { ListView } from '@web/components/listView'; + +type ConsoleEntry = { + message?: channels.ConsoleMessageInitializer; + error?: channels.SerializedError; + highlight: boolean; +}; + +const ConsoleListView = ListView; export const ConsoleTab: React.FunctionComponent<{ + model: modelUtil.MultiTraceModel | undefined, action: ActionTraceEvent | undefined, -}> = ({ action }) => { - const entries = React.useMemo(() => { - if (!action) - return []; - const entries: { message?: channels.ConsoleMessageInitializer, error?: channels.SerializedError }[] = []; - const context = modelUtil.context(action); - for (const event of modelUtil.eventsForAction(action)) { +}> = ({ model, action }) => { + const { entries } = React.useMemo(() => { + if (!model) + return { entries: [] }; + const entries: ConsoleEntry[] = []; + const actionEvents = action ? modelUtil.eventsForAction(action) : []; + for (const event of model.events) { if (event.method !== 'console' && event.method !== 'pageError') continue; if (event.method === 'console') { const { guid } = event.params.message; - entries.push({ message: context.initializers[guid] }); + entries.push({ + message: modelUtil.context(event).initializers[guid], + highlight: actionEvents.includes(event), + }); + } + if (event.method === 'pageError') { + entries.push({ + error: event.params.error, + highlight: actionEvents.includes(event), + }); } - if (event.method === 'pageError') - entries.push({ error: event.params.error }); } - return entries; - }, [action]); + return { entries }; + }, [model, action]); - return
{ - entries.map((entry, index) => { - const { message, error } = entry; - if (message) { - const url = message.location.url; - const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; - return
- {filename}:{message.location.lineNumber} - - {message.text} -
; - } - if (error) { - const { error: errorObject, value } = error; - if (errorObject) { - return
- - {errorObject.message} -
{errorObject.stack}
-
; - } else { - return
- - {String(value)} + return
+ !!entry.error || entry.message?.type === 'error'} + isWarning={entry => entry.message?.type === 'warning'} + render={entry => { + const { message, error } = entry; + if (message) { + const url = message.location.url; + const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; + return
+ {filename}:{message.location.lineNumber} + + {message.text}
; } - } - return null; - }) - }
; + if (error) { + const { error: errorObject, value } = error; + if (errorObject) { + return
+ + {errorObject.message} +
{errorObject.stack}
+
; + } else { + return
+ + {String(value)} +
; + } + } + return null; + }} + isHighlighted={entry => !!entry.highlight} + /> +
; }; function iconClass(message: channels.ConsoleMessageInitializer): string { diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 6793c51fae..73b7457aad 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -63,6 +63,7 @@ export class MultiTraceModel { readonly sdkLanguage: Language | undefined; readonly testIdAttributeName: string | undefined; readonly sources: Map; + resources: ResourceSnapshot[]; constructor(contexts: ContextEntry[]) { @@ -81,6 +82,7 @@ export class MultiTraceModel { this.actions = mergeActions(contexts); this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events)); this.hasSource = contexts.some(c => c.hasSource); + this.resources = [...contexts.map(c => c.resources)].flat(); this.events.sort((a1, a2) => a1.time - a2.time); this.sources = collectSources(this.actions); @@ -191,7 +193,7 @@ export function idForAction(action: ActionTraceEvent) { return `${action.pageId || 'none'}:${action.callId}`; } -export function context(action: ActionTraceEvent): ContextEntry { +export function context(action: ActionTraceEvent | EventTraceEvent): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 3846e37c49..45a61ab8f2 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -23,10 +23,6 @@ outline: none; } -.network-request.selected:focus { - border-color: var(--orange); -} - .network-request-title { height: 28px; display: flex; @@ -34,6 +30,10 @@ flex: 1; } +.network-request.highlighted { + background-color: var(--vscode-list-inactiveSelectionBackground); +} + .network-request-title-status { padding: 0 2px; border-radius: 4px; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index a5e8f48f2e..68c9edbd69 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -22,18 +22,15 @@ import type { Entry } from '@trace/har'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, - index: number, - selected: boolean, - setSelected: React.Dispatch>, -}> = ({ resource, index, selected, setSelected }) => { + highlighted: boolean, +}> = ({ resource, highlighted }) => { const [expanded, setExpanded] = React.useState(false); const [requestBody, setRequestBody] = React.useState(null); const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string } | null>(null); React.useEffect(() => { setExpanded(false); - setSelected(-1); - }, [resource, setSelected]); + }, [resource]); React.useEffect(() => { const readResources = async () => { @@ -89,7 +86,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ }, [contentType, resource, resourceName, routeStatus]); return
setSelected(index)}> + className={'network-request' + (highlighted ? ' highlighted' : '')}>
{resource.time}ms
diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index b89c1e66ba..6ddaeac76d 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -21,14 +21,18 @@ import { NetworkResourceDetails } from './networkResourceDetails'; import './networkTab.css'; export const NetworkTab: React.FunctionComponent<{ + model: modelUtil.MultiTraceModel | undefined, action: ActionTraceEvent | undefined, -}> = ({ action }) => { - const [selected, setSelected] = React.useState(0); - - const resources = action ? modelUtil.resourcesForAction(action) : []; +}> = ({ model, action }) => { + const actionResources = action ? modelUtil.resourcesForAction(action) : []; + const resources = model?.resources || []; return
{ resources.map((resource, index) => { - return ; + return ; }) }
; }; diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index 7f1bcd40cd..a874fa58db 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -61,7 +61,7 @@ } .snapshot-tab:focus .snapshot-toggle.toggled { - background: var(--blue); + background: var(--vscode-charts-blue); } .snapshot-wrapper { diff --git a/packages/trace-viewer/src/ui/timeline.css b/packages/trace-viewer/src/ui/timeline.css index 179a63e1c3..4f010d56fb 100644 --- a/packages/trace-viewer/src/ui/timeline.css +++ b/packages/trace-viewer/src/ui/timeline.css @@ -88,7 +88,7 @@ .timeline-bar.frame_check, .timeline-bar.frame_uncheck, .timeline-bar.frame_tap { - --action-color: var(--green); + --action-color: var(--vscode-charts-green); } .timeline-bar.page_load, @@ -110,11 +110,11 @@ .timeline-bar.frame_goback, .timeline-bar.frame_goforward, .timeline-bar.reload { - --action-color: var(--blue); + --action-color: var(--vscode-charts-blue); } .timeline-bar.frame_evaluateexpression { - --action-color: var(--yellow); + --action-color: var(--vscode-charts-yellow); } .timeline-bar.frame_dialog { @@ -122,7 +122,7 @@ } .timeline-bar.frame_navigated { - --action-color: var(--blue); + --action-color: var(--vscode-charts-blue); } .timeline-bar.frame_waitforeventinfo, diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index be206ca558..f8685f515b 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { ActionList } from './actionList'; import { CallTab } from './callTab'; import { ConsoleTab } from './consoleTab'; -import * as modelUtil from './modelUtil'; +import type * as modelUtil from './modelUtil'; import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import { NetworkTab } from './networkTab'; import { SnapshotTab } from './snapshotTab'; @@ -68,9 +68,6 @@ export const Workbench: React.FunctionComponent<{ onSelectionChanged?.(action); }, [setSelectedAction, onSelectionChanged]); - const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 }; - const consoleCount = errors + warnings; - const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0; const sdkLanguage = model?.sdkLanguage || 'javascript'; const callTab: TabbedPaneTabModel = { @@ -91,19 +88,17 @@ export const Workbench: React.FunctionComponent<{ const consoleTab: TabbedPaneTabModel = { id: 'console', title: 'Console', - count: consoleCount, - render: () => + render: () => }; const networkTab: TabbedPaneTabModel = { id: 'network', title: 'Network', - count: networkCount, - render: () => + render: () => }; const attachmentsTab: TabbedPaneTabModel = { id: 'attachments', title: 'Attachments', - render: () => + render: () => }; const tabs: TabbedPaneTabModel[] = showSourcesFirst ? [ @@ -136,7 +131,6 @@ export const Workbench: React.FunctionComponent<{ { id: 'actions', title: 'Actions', - count: 0, component: }, ] diff --git a/packages/web/src/common.css b/packages/web/src/common.css index db1e47e8aa..dcceb2298d 100644 --- a/packages/web/src/common.css +++ b/packages/web/src/common.css @@ -19,28 +19,13 @@ } body { - --red: #F44336; - --green: #367c39; - --purple: #9C27B0; - --yellow: #ff9207; - --white: #FFFFFF; - --blue: #0b7ad5; --transparent-blue: #2196F355; - --orange: #d24726; --light-pink: #ff69b460; --gray: #888888; --sidebar-width: 250px; --box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px; } -body.dark-mode { - --green: #28d12f; - --yellow: #ff9207; - --purple: #dc12ff; - --blue: #4dafff; - --orange: #ff9800; -} - html, body { width: 100%; height: 100%; @@ -103,11 +88,15 @@ svg { } .codicon-check { - color: var(--green); + color: var(--vscode-charts-green); } .codicon-error { - color: var(--red); + color: var(--vscode-errorForeground); +} + +.codicon-warning { + color: var(--vscode-list-warningForeground); } .codicon-circle-outline { diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css index d14425a8c0..3fbb74f3d0 100644 --- a/packages/web/src/components/listView.css +++ b/packages/web/src/components/listView.css @@ -20,7 +20,7 @@ flex: auto; position: relative; user-select: none; - overflow: auto; + overflow-y: auto; outline: 1px solid transparent; } @@ -75,3 +75,7 @@ .list-view-entry.error { color: var(--vscode-list-errorForeground); } + +.list-view-entry.warning { + color: var(--vscode-list-warningForeground); +} diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 6d35fbe581..8f4c4a3226 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -19,18 +19,20 @@ import './listView.css'; export type ListViewProps = { items: T[], - id?: (item: T) => string, - render: (item: T) => React.ReactNode, - icon?: (item: T) => string | undefined, - indent?: (item: T) => number | undefined, - isError?: (item: T) => boolean, + id?: (item: T, index: number) => string, + render: (item: T, index: number) => React.ReactNode, + icon?: (item: T, index: number) => string | undefined, + indent?: (item: T, index: number) => number | undefined, + isError?: (item: T, index: number) => boolean, + isWarning?: (item: T, index: number) => boolean, + isHighlighted?: (item: T, index: number) => boolean, selectedItem?: T, - onAccepted?: (item: T) => void, - onSelected?: (item: T) => void, - onLeftArrow?: (item: T) => void, - onRightArrow?: (item: T) => void, + onAccepted?: (item: T, index: number) => void, + onSelected?: (item: T, index: number) => void, + onLeftArrow?: (item: T, index: number) => void, + onRightArrow?: (item: T, index: number) => void, onHighlighted?: (item: T | undefined) => void, - onIconClicked?: (item: T) => void, + onIconClicked?: (item: T, index: number) => void, noItemsMessage?: string, dataTestId?: string, }; @@ -41,6 +43,8 @@ export function ListView({ render, icon, isError, + isWarning, + isHighlighted, indent, selectedItem, onAccepted, @@ -63,10 +67,10 @@ export function ListView({
selectedItem && onAccepted?.(selectedItem)} + onDoubleClick={() => selectedItem && onAccepted?.(selectedItem, items.indexOf(selectedItem))} onKeyDown={event => { if (selectedItem && event.key === 'Enter') { - onAccepted?.(selectedItem); + onAccepted?.(selectedItem, items.indexOf(selectedItem)); return; } if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') @@ -76,11 +80,11 @@ export function ListView({ event.preventDefault(); if (selectedItem && event.key === 'ArrowLeft') { - onLeftArrow?.(selectedItem); + onLeftArrow?.(selectedItem, items.indexOf(selectedItem)); return; } if (selectedItem && event.key === 'ArrowRight') { - onRightArrow?.(selectedItem); + onRightArrow?.(selectedItem, items.indexOf(selectedItem)); return; } @@ -102,28 +106,29 @@ export function ListView({ const element = itemListRef.current?.children.item(newIndex); scrollIntoViewIfNeeded(element || undefined); onHighlighted?.(undefined); - onSelected?.(items[newIndex]); + onSelected?.(items[newIndex], newIndex); }} ref={itemListRef} > {noItemsMessage && items.length === 0 &&
{noItemsMessage}
} {items.map((item, index) => { const selectedSuffix = selectedItem === item ? ' selected' : ''; - const highlightedSuffix = highlightedItem === item ? ' highlighted' : ''; - const errorSuffix = isError?.(item) ? ' error' : ''; - const indentation = indent?.(item) || 0; - const rendered = render(item); + const highlightedSuffix = isHighlighted?.(item, index) || highlightedItem === item ? ' highlighted' : ''; + const errorSuffix = isError?.(item, index) ? ' error' : ''; + const warningSuffix = isWarning?.(item, index) ? ' warning' : ''; + const indentation = indent?.(item, index) || 0; + const rendered = render(item, index); return
onSelected?.(item)} + className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix + warningSuffix} + onClick={() => onSelected?.(item, index)} onMouseEnter={() => setHighlightedItem(item)} onMouseLeave={() => setHighlightedItem(undefined)} > {indentation ? new Array(indentation).fill(0).map(() =>
) : undefined} {icon &&
{ e.preventDefault(); @@ -132,7 +137,7 @@ export function ListView({ onClick={e => { e.stopPropagation(); e.preventDefault(); - onIconClicked?.(item); + onIconClicked?.(item, index); }} >
} {typeof rendered === 'string' ?
{rendered}
: rendered} diff --git a/packages/web/src/components/tabbedPane.css b/packages/web/src/components/tabbedPane.css index 3ad340f9ec..d1989c5ecb 100644 --- a/packages/web/src/components/tabbedPane.css +++ b/packages/web/src/components/tabbedPane.css @@ -51,13 +51,6 @@ display: inline-block; } -.tabbed-pane-tab-count { - font-size: 10px; - display: flex; - align-self: flex-start; - width: 0px; -} - .tabbed-pane-tab.selected { background-color: var(--vscode-tab-activeBackground); } diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index 61474f0ee7..2c0b9e205f 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -21,7 +21,6 @@ import * as React from 'react'; export interface TabbedPaneTabModel { id: string; title: string | JSX.Element; - count?: number; component?: React.ReactElement; render?: () => React.ReactElement; } @@ -41,7 +40,6 @@ export const TabbedPane: React.FunctionComponent<{ )), @@ -63,14 +61,12 @@ export const TabbedPane: React.FunctionComponent<{ export const TabbedPaneTab: React.FunctionComponent<{ id: string, title: string | JSX.Element, - count?: number, selected?: boolean, onSelect: (id: string) => void -}> = ({ id, title, count, selected, onSelect }) => { +}> = ({ id, title, selected, onSelect }) => { return
onSelect(id)} key={id}>
{title}
-
{count || ''}
; }; diff --git a/tests/assets/frames/frame.html b/tests/assets/frames/frame.html index 4fa926868f..38360b0911 100644 --- a/tests/assets/frames/frame.html +++ b/tests/assets/frames/frame.html @@ -1,3 +1,4 @@ +