diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 9c68271e80..d8fa8230c6 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -348,10 +348,10 @@ type CaptureOptions = { fullPage: boolean; }; -async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { +async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { validateOptions(options); const browserType = lookupBrowserType(options); - const launchOptions: LaunchOptions = { headless, executablePath }; + const launchOptions: LaunchOptions = extraOptions; if (options.channel) launchOptions.channel = options.channel as any; launchOptions.handleSIGINT = false; @@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath // In headful mode, use host device scale factor for things to look nice. // In headless, keep things the way it works in Playwright by default. // Assume high-dpi on MacOS. TODO: this is not perfect. - if (!headless) + if (!extraOptions.headless) contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; // Work around the WebKit GTK scrolling issue. @@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi } async function open(options: Options, url: string | undefined, language: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH }); await context._enableRecorder({ language, launchOptions, @@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`); + const { context, launchOptions, contextOptions } = await launchContext(options, { + headless: !!process.env.PWTEST_CLI_HEADLESS, + executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, + tracesDir, + }); dotenv.config({ path: 'playwright.env' }); + if (process.env.PW_RECORDER_IS_TRACE_VIEWER) { + await fs.promises.mkdir(tracesDir, { recursive: true }); + await context.tracing.start({ name: 'trace', _live: true }); + } await context._enableRecorder({ language, launchOptions, @@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) { } async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { - const { context } = await launchContext(options, true); + const { context } = await launchContext(options, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); @@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url: async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { if (options.browser !== 'chromium') throw new Error('PDF creation is only working with Chromium'); - const { context } = await launchContext({ ...options, browser: 'chromium' }, true); + const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c70d8e825a..5c8fa550a7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -39,6 +39,7 @@ import type { Dialog } from '../dialog'; import type { ConsoleMessage } from '../console'; import { serializeError } from '../errors'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; +import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer'; import { RecorderApp } from '../recorder/recorderApp'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { @@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher { - await Recorder.show(this._context, RecorderApp.factory(this._context), params); + const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context); + await Recorder.show(this._context, factory, params); } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 97316c2f9e..5e197f871f 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -26,14 +26,12 @@ import { type Language } from './codegen/types'; import { Debugger } from './debugger'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; -import { type IRecorderApp } from './recorder/recorderApp'; +import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); -export type RecorderAppFactory = (recorder: Recorder) => Promise; - -export class Recorder implements InstrumentationListener { +export class Recorder implements InstrumentationListener, IRecorder { private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; @@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener { private _omitCallTracking = false; private _currentLanguage: Language; - static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { + static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { const params: channels.BrowserContextRecorderSupplementEnableParams = {}; if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; Recorder.show(context, recorderAppFactory, params).catch(() => {}); } - static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { recorderPromise = Recorder._create(context, recorderAppFactory, params); @@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener { return recorderPromise; } - private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { const recorder = new Recorder(context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index 22ec3dfc2f..f3bbfc23bf 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -10,3 +10,6 @@ ../../utils/** ../../utilsBundle.ts ../../zipBundle.ts + +[recorderInTraceViewer.ts] +../trace/viewer/traceViewer.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 88aeacc368..9b4efb9e65 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -69,7 +69,7 @@ export class ContextRecorder extends EventEmitter { // Make a copy of options to modify them later. const languageGeneratorOptions: LanguageGeneratorOptions = { browserName: context._browser.options.name, - launchOptions: { headless: false, ...params.launchOptions }, + launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined }, contextOptions: { ...params.contextOptions }, deviceName: params.device, saveStorage: params.saveStorage, diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 0faf191ea5..8044fadf41 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes'; import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { syncLocalStorageWithSettings } from '../launchApp'; -import type { Recorder, RecorderAppFactory } from '../recorder'; import type { BrowserContext } from '../browserContext'; import { launchApp } from '../launchApp'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; declare global { interface Window { @@ -42,16 +42,6 @@ declare global { } } -export interface IRecorderApp extends EventEmitter { - close(): Promise; - setPaused(paused: boolean): Promise; - setMode(mode: Mode): Promise; - setFileIfNeeded(file: string): Promise; - setSelector(selector: string, userGesture?: boolean): Promise; - updateCallLogs(callLogs: CallLog[]): Promise; - setSources(sources: Source[]): Promise; -} - export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} @@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; readonly wsEndpoint: string | undefined; - private _recorder: Recorder; + private _recorder: IRecorder; - constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { + constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { super(); this.setMaxListeners(0); this._recorder = recorder; @@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); } - static factory(context: BrowserContext): RecorderAppFactory { + static factory(context: BrowserContext): IRecorderAppFactory { return async recorder => { if (process.env.PW_CODEGEN_NO_INSPECTOR) return new EmptyRecorderApp(); @@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }; } - private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise { + private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise { const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts new file mode 100644 index 0000000000..161aa71eca --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import type { EventEmitter } from 'events'; + +export interface IRecorder { + setMode(mode: Mode): void; + mode(): Mode; +} + +export interface IRecorderApp extends EventEmitter { + close(): Promise; + setPaused(paused: boolean): Promise; + setMode(mode: Mode): Promise; + setFileIfNeeded(file: string): Promise; + setSelector(selector: string, userGesture?: boolean): Promise; + updateCallLogs(callLogs: CallLog[]): Promise; + setSources(sources: Source[]): Promise; +} + +export type IRecorderAppFactory = (recorder: IRecorder) => Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts new file mode 100644 index 0000000000..f7613ffc54 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import { EventEmitter } from 'events'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; +import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; +import type { BrowserContext } from '../browserContext'; +import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; +import type { Transport } from '../../utils/httpServer'; + +export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { + private _recorder: IRecorder; + private _transport: Transport; + + static factory(context: BrowserContext): IRecorderAppFactory { + return async (recorder: IRecorder) => { + const transport = new RecorderTransport(); + const trace = path.join(context._browser.options.tracesDir, 'trace'); + await openApp(trace, { transport }); + return new RecorderInTraceViewer(context, recorder, transport); + }; + } + + constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) { + super(); + this._recorder = recorder; + this._transport = transport; + } + + async close(): Promise { + this._transport.sendEvent?.('close', {}); + } + + async setPaused(paused: boolean): Promise { + this._transport.sendEvent?.('setPaused', { paused }); + } + + async setMode(mode: Mode): Promise { + this._transport.sendEvent?.('setMode', { mode }); + } + + async setFileIfNeeded(file: string): Promise { + this._transport.sendEvent?.('setFileIfNeeded', { file }); + } + + async setSelector(selector: string, userGesture?: boolean): Promise { + this._transport.sendEvent?.('setSelector', { selector, userGesture }); + } + + async updateCallLogs(callLogs: CallLog[]): Promise { + this._transport.sendEvent?.('updateCallLogs', { callLogs }); + } + + async setSources(sources: Source[]): Promise { + this._transport.sendEvent?.('setSources', { sources }); + } +} + +async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) { + const server = await startTraceViewerServer(options); + await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); + const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); + page.on('close', () => gracefullyProcessExitDoNotHang(0)); +} + +class RecorderTransport implements Transport { + constructor() { + } + + async dispatch(method: string, params: any) { + } + + onclose() { + } + + sendEvent?: (method: string, params: any) => void; + close?: () => void; +} diff --git a/packages/trace-viewer/recorder.html b/packages/trace-viewer/recorder.html new file mode 100644 index 0000000000..c33d6586e5 --- /dev/null +++ b/packages/trace-viewer/recorder.html @@ -0,0 +1,28 @@ + + + + + + + + Playwright Recorder + + +
+ + + diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx new file mode 100644 index 0000000000..4de705d4fc --- /dev/null +++ b/packages/trace-viewer/src/recorder.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@web/common.css'; +import { applyTheme } from '@web/theme'; +import '@web/third_party/vscode/codicon.css'; +import * as ReactDOM from 'react-dom/client'; +import { RecorderView } from './ui/recorderView'; + +(async () => { + applyTheme(); + + if (window.location.protocol !== 'file:') { + if (!navigator.serviceWorker) + throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); + navigator.serviceWorker.register('sw.bundle.js'); + if (!navigator.serviceWorker.controller) { + await new Promise(f => { + navigator.serviceWorker.oncontrollerchange = () => f(); + }); + } + + // Keep SW running. + setInterval(function() { fetch('ping'); }, 10000); + } + + ReactDOM.createRoot(document.querySelector('#root')!).render(); +})(); diff --git a/packages/trace-viewer/src/ui/recorderView.css b/packages/trace-viewer/src/ui/recorderView.css new file mode 100644 index 0000000000..ad03e78e7d --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.css @@ -0,0 +1,15 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx new file mode 100644 index 0000000000..940fd146a9 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -0,0 +1,168 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; +import './recorderView.css'; +import { MultiTraceModel } from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { Workbench } from './workbench'; +import type { Mode, Source } from '@recorder/recorderTypes'; +import type { ContextEntry } from '../entries'; + +const searchParams = new URLSearchParams(window.location.search); +const guid = searchParams.get('ws'); +const trace = searchParams.get('trace') + '.json'; + +export const RecorderView: React.FunctionComponent = () => { + const [connection, setConnection] = React.useState(null); + const [sources, setSources] = React.useState([]); + React.useEffect(() => { + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const webSocket = new WebSocket(wsURL.toString()); + setConnection(new Connection(webSocket, { setSources })); + return () => { + webSocket.close(); + }; + }, []); + + React.useEffect(() => { + if (!connection) + return; + connection.setMode('recording'); + }, [connection]); + + return
+ +
; +}; + +export const TraceView: React.FC<{ + traceLocation: string, + sources: Source[], +}> = ({ traceLocation, sources }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + + React.useEffect(() => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + + // Start polling running test. + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel({ model, isLive: true }); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } + }, 500); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [counter, traceLocation]); + + const fallbackLocation = React.useMemo(() => { + if (!sources.length) + return undefined; + const fallbackLocation: SourceLocation = { + file: '', + line: 0, + column: 0, + source: { + errors: [], + content: sources[0].text + } + }; + return fallbackLocation; + }, [sources]); + + return ; +}; + +async function loadSingleTraceFile(url: string): Promise { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + return new MultiTraceModel(contextEntries); +} + +class Connection { + private _lastId = 0; + private _webSocket: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _options: { setSources: (sources: Source[]) => void; }; + + constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + this._webSocket = webSocket; + this._callbacks = new Map(); + this._options = options; + + this._webSocket.addEventListener('message', event => { + const message = JSON.parse(event.data); + const { id, result, error, method, params } = message; + if (id) { + const callback = this._callbacks.get(id); + if (!callback) + return; + this._callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); + } else { + this._dispatchEvent(method, params); + } + }); + } + + setMode(mode: Mode) { + this._sendMessageNoReply('setMode', { mode }); + } + + private async _sendMessage(method: string, params?: any): Promise { + const id = ++this._lastId; + const message = { id, method, params }; + this._webSocket.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); + }); + } + + private _sendMessageNoReply(method: string, params?: any) { + this._sendMessage(method, params).catch(() => { }); + } + + private _dispatchEvent(method: string, params?: any) { + if (method === 'setSources') { + const { sources } = params as { sources: Source[] }; + this._options.setSources(sources); + } + } +} diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index cf704a8438..ce54b34d53 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{ let source = sources.get(file); // Fallback location can fall outside the sources model. if (!source) { - source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; + source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content }; sources.set(file, source); } @@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{ highlight.push({ line: targetLine, type: 'running' }); // After the source update, but before the test run, don't trust the cache. - if (source.content === undefined || shouldUseFallback) { + if (fallbackLocation?.source?.content !== undefined) { + source.content = fallbackLocation.source.content; + } else if (source.content === undefined || shouldUseFallback) { const sha1 = await calculateSha1(file); try { let response = await fetch(`sha1/src@${sha1}.txt`); diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 13310ca0f7..0e2e9cb642 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ index: path.resolve(__dirname, 'index.html'), uiMode: path.resolve(__dirname, 'uiMode.html'), embedded: path.resolve(__dirname, 'embedded.html'), + recorder: path.resolve(__dirname, 'recorder.html'), snapshot: path.resolve(__dirname, 'snapshot.html'), }, output: {