diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 7caaffe5cdfe6..0e83a15f387f7 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -55,25 +55,26 @@ export type TraceViewerAppOptions = { const tracesDirMarker = 'traces.dir'; -function validateTraceUrl(traceUrl: string | undefined): string | undefined { - if (!traceUrl) - return traceUrl; +function validateTraceUrl(traceFileOrUrl: string | undefined): string | undefined { + if (!traceFileOrUrl) + return traceFileOrUrl; - if (traceUrl.startsWith('http://') || traceUrl.startsWith('https://')) - return traceUrl; + if (traceFileOrUrl.startsWith('http://') || traceFileOrUrl.startsWith('https://')) + return traceFileOrUrl; + let traceFile = traceFileOrUrl; // If .json is requested, we'll synthesize it. - if (traceUrl.endsWith('.json')) - return traceUrl; + if (traceFile.endsWith('.json')) + return toFilePathUrl(traceFile); try { - const stat = fs.statSync(traceUrl); + const stat = fs.statSync(traceFile); // If the path is a directory, add 'trace.dir' which has a special handler. if (stat.isDirectory()) - return path.join(traceUrl, tracesDirMarker); - return traceUrl; + traceFile = path.join(traceFile, tracesDirMarker); + return toFilePathUrl(traceFile); } catch { - throw new Error(`Trace file ${traceUrl} does not exist!`); + throw new Error(`Trace file ${traceFileOrUrl} does not exist!`); } } @@ -270,13 +271,18 @@ function traceDescriptor(traceDir: string, tracePrefix: string | undefined) { for (const name of fs.readdirSync(traceDir)) { if (!tracePrefix || name.startsWith(tracePrefix)) - result.entries.push({ name, path: path.join(traceDir, name) }); + result.entries.push({ name, path: toFilePathUrl(path.join(traceDir, name)) }); } const resourcesDir = path.join(traceDir, 'resources'); if (fs.existsSync(resourcesDir)) { for (const name of fs.readdirSync(resourcesDir)) - result.entries.push({ name: 'resources/' + name, path: path.join(resourcesDir, name) }); + result.entries.push({ name: 'resources/' + name, path: toFilePathUrl(path.join(resourcesDir, name)) }); } return result; } + + +function toFilePathUrl(filePath: string): string { + return `file?path=${encodeURIComponent(filePath)}`; +} diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts index 3f0b3ffdab875..1f3fc7359ff2b 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceLoader.ts @@ -27,7 +27,6 @@ export interface TraceLoaderBackend { readText(entryName: string): Promise; readBlob(entryName: string): Promise; isLive(): boolean; - traceURL(): string; } export class TraceLoader { diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index 4483815a3b1fd..2b0594705af43 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -86,14 +86,14 @@ export class TraceModel { readonly sources: Map; resources: ResourceSnapshot[]; readonly actionCounters: Map; - readonly traceUrl: string; + readonly traceUri: string; - constructor(traceUrl: string, contexts: ContextEntry[]) { + constructor(traceUri: string, contexts: ContextEntry[]) { contexts.forEach(contextEntry => indexModel(contextEntry)); const libraryContext = contexts.find(context => context.origin === 'library'); - this.traceUrl = traceUrl; + this.traceUri = traceUri; this.browserName = libraryContext?.browserName || ''; this.sdkLanguage = libraryContext?.sdkLanguage; this.channel = libraryContext?.channel; @@ -114,7 +114,7 @@ export class TraceModel { this.hasSource = contexts.some(c => c.hasSource); this.hasStepData = contexts.some(context => context.origin === 'testRunner'); this.resources = [...contexts.map(c => c.resources)].flat(); - this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUrl })) ?? []); + this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []); this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); this.events.sort((a1, a2) => a1.time - a2.time); @@ -132,7 +132,7 @@ export class TraceModel { createRelativeUrl(path: string) { const url = new URL('http://localhost/' + path); - url.searchParams.set('trace', this.traceUrl); + url.searchParams.set('trace', this.traceUri); return url.toString().substring('http://localhost/'.length); } diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index 46a7175723749..02cb68aeaff0d 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -19,7 +19,7 @@ import { TraceLoader } from '@isomorphic/trace/traceLoader'; import { TraceVersionError } from '@isomorphic/trace/traceModernizer'; import { Progress, splitProgress } from './progress'; -import { FetchTraceLoaderBackend, traceFileURL, ZipTraceLoaderBackend } from './traceLoaderBackends'; +import { FetchTraceLoaderBackend, ZipTraceLoaderBackend } from './traceLoaderBackends'; type Client = { id: string; @@ -74,9 +74,9 @@ function simulateRestart() { clientIdToTraceUrls.clear(); } -async function loadTraceOrError(clientId: string, url: URL, isContextRequest: boolean, progress: Progress): Promise<{ loadedTrace?: LoadedTrace, errorResponse?: Response }> { +async function loadTraceOrError(clientId: string, url: URL, progress: Progress): Promise<{ loadedTrace?: LoadedTrace, errorResponse?: Response }> { try { - const loadedTrace = await loadTrace(clientId, url, isContextRequest, progress); + const loadedTrace = await loadTrace(clientId, url, progress); return { loadedTrace }; } catch (error) { return { @@ -88,30 +88,29 @@ async function loadTraceOrError(clientId: string, url: URL, isContextRequest: bo } } -function loadTrace(clientId: string, url: URL, isContextRequest: boolean, progress: Progress): Promise { - const traceUrl = url.searchParams.get('trace')!; - if (!traceUrl) +function loadTrace(clientId: string, url: URL, progress: Progress): Promise { + const traceUri = url.searchParams.get('trace')!; + if (!traceUri) throw new Error('trace parameter is missing'); - clientIdToTraceUrls.set(clientId, traceUrl); - const omitCache = isContextRequest && isLiveTrace(traceUrl); - const loadedTrace = omitCache ? undefined : loadedTraces.get(traceUrl); + clientIdToTraceUrls.set(clientId, traceUri); + const loadedTrace = loadedTraces.get(traceUri); if (loadedTrace) return loadedTrace; - const promise = innerLoadTrace(traceUrl, progress); - loadedTraces.set(traceUrl, promise); - promise.catch(() => loadedTraces.delete(traceUrl)); + const promise = innerLoadTrace(traceUri, progress); + loadedTraces.set(traceUri, promise); + promise.catch(() => loadedTraces.delete(traceUri)); return promise; } -async function innerLoadTrace(traceUrl: string, progress: Progress): Promise { +async function innerLoadTrace(traceUri: string, progress: Progress): Promise { await gc(); const traceLoader = new TraceLoader(); try { // Allow 10% to hop from sw to page. const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); - const backend = isLiveTrace(traceUrl) || traceUrl.endsWith('traces.dir') ? new FetchTraceLoaderBackend(traceUrl) : new ZipTraceLoaderBackend(traceUrl, fetchProgress); + const backend = isLiveTrace(traceUri) || traceUri.endsWith('traces.dir') ? new FetchTraceLoaderBackend(traceUri) : new ZipTraceLoaderBackend(traceUri, fetchProgress); await traceLoader.load(backend, unzipProgress); } catch (error: any) { // eslint-disable-next-line no-console @@ -119,9 +118,9 @@ async function innerLoadTrace(traceUrl: string, progress: Progress): Promise { }); if (lnaPermission && lnaPermission.state !== 'granted') @@ -162,7 +161,7 @@ async function doFetch(event: FetchEvent): Promise { // Snapshot iframe navigation request. if (relativePath?.startsWith('/snapshot/')) { // It is Ok to pass noop progress as the trace is likely already loaded. - const { errorResponse, loadedTrace } = await loadTraceOrError(event.resultingClientId!, url, false, noopProgress); + const { errorResponse, loadedTrace } = await loadTraceOrError(event.resultingClientId!, url, noopProgress); if (errorResponse) return errorResponse; const pageOrFrameId = relativePath.substring('/snapshot/'.length); @@ -181,7 +180,7 @@ async function doFetch(event: FetchEvent): Promise { if (!client) return new Response('Sub-resource without a client', { status: 500 }); - const { snapshotServer } = await loadTrace(client.id, new URL(client.url), false, clientProgress(client)); + const { snapshotServer } = await loadTrace(client.id, new URL(client.url), clientProgress(client)); if (!snapshotServer) return new Response(null, { status: 404 }); @@ -201,8 +200,7 @@ async function doFetch(event: FetchEvent): Promise { if (!client) return new Response('Sub-resource without a client', { status: 500 }); - const isContextRequest = relativePath === '/contexts'; - const { errorResponse, loadedTrace } = await loadTraceOrError(client.id, url, isContextRequest, clientProgress(client)); + const { errorResponse, loadedTrace } = await loadTraceOrError(client.id, url, clientProgress(client)); if (errorResponse) return errorResponse; @@ -231,13 +229,7 @@ async function doFetch(event: FetchEvent): Promise { } } - // Pass through to the server for file requests. - if (relativePath?.startsWith('/file/')) { - const path = url.searchParams.get('path')!; - return await fetch(traceFileURL(path)); - } - - // Static content for sub-resource. + // Pass through to the server for file requests and static content. return fetch(event.request); } @@ -279,8 +271,10 @@ function clientProgress(client: Client): Progress { function noopProgress(done: number, total: number): undefined { } -function isLiveTrace(traceUrl: string): boolean { - return traceUrl.endsWith('.json'); +function isLiveTrace(traceUri: string): boolean { + const url = new URL(traceUri, 'http://localhost'); + const path = url.searchParams.get('path'); + return !!path?.endsWith('.json'); } self.addEventListener('fetch', function(event: FetchEvent) { diff --git a/packages/trace-viewer/src/sw/traceLoaderBackends.ts b/packages/trace-viewer/src/sw/traceLoaderBackends.ts index 6ee4cb4d52033..bd1149edf8210 100644 --- a/packages/trace-viewer/src/sw/traceLoaderBackends.ts +++ b/packages/trace-viewer/src/sw/traceLoaderBackends.ts @@ -26,14 +26,12 @@ type Progress = (done: number, total: number) => undefined; export class ZipTraceLoaderBackend implements TraceLoaderBackend { private _zipReader: zip.ZipReader; private _entriesPromise: Promise>; - private _traceURL: string; - constructor(traceURL: string, progress: Progress) { - this._traceURL = traceURL; + constructor(traceUri: string, progress: Progress) { zipjs.configure({ baseURL: self.location.href } as any); this._zipReader = new zipjs.ZipReader( - new zipjs.HttpReader(this._resolveTraceURL(traceURL), { mode: 'cors', preventHeadRequest: true } as any), + new zipjs.HttpReader(this._resolveTraceURI(traceUri), { mode: 'cors', preventHeadRequest: true } as any), { useWebWorkers: false }); this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => { const map = new Map(); @@ -43,26 +41,16 @@ export class ZipTraceLoaderBackend implements TraceLoaderBackend { }); } - private _resolveTraceURL(traceURL: string): string { - let url: string; - if (traceURL.startsWith('http') || traceURL.startsWith('blob')) { - url = traceURL; - if (url.startsWith('https://www.dropbox.com/')) - url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length); - } else { - url = traceFileURL(traceURL); - } - return url; + private _resolveTraceURI(traceUri: string): string { + if (traceUri.startsWith('https://www.dropbox.com/')) + return 'https://dl.dropboxusercontent.com/' + traceUri.substring('https://www.dropbox.com/'.length); + return traceUri; } isLive() { return false; } - traceURL() { - return this._traceURL; - } - async entryNames(): Promise { const entries = await this._entriesPromise; return [...entries.keys()]; @@ -96,11 +84,9 @@ export class ZipTraceLoaderBackend implements TraceLoaderBackend { export class FetchTraceLoaderBackend implements TraceLoaderBackend { private _entriesPromise: Promise>; - private _path: string; - constructor(path: string) { - this._path = path; - this._entriesPromise = this._readFile(path).then(async response => { + constructor(traceUri: string) { + this._entriesPromise = this._readFile(traceUri).then(async response => { if (!response) throw new Error('File not found'); const json = await response.json(); @@ -115,10 +101,6 @@ export class FetchTraceLoaderBackend implements TraceLoaderBackend { return true; } - traceURL(): string { - return this._path; - } - async entryNames(): Promise { const entries = await this._entriesPromise; return [...entries.keys()]; @@ -141,20 +123,16 @@ export class FetchTraceLoaderBackend implements TraceLoaderBackend { private async _readEntry(entryName: string): Promise { const entries = await this._entriesPromise; - const fileName = entries.get(entryName); - if (!fileName) + const fileUri = entries.get(entryName); + if (!fileUri) return; - return this._readFile(fileName); + return this._readFile(fileUri); } - private async _readFile(path: string): Promise { - const response = await fetch(traceFileURL(path)); + private async _readFile(uri: string): Promise { + const response = await fetch(uri); if (response.status === 404) return; return response; } } - -export function traceFileURL(path: string): string { - return `file?path=${encodeURIComponent(path)}`; -} diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 94cf2c1be40c8..9064ee396d153 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -58,7 +58,7 @@ export const SnapshotTabsView: React.FunctionComponent<{ }, [action]); const { snapshotInfoUrl, snapshotUrl, popoutUrl } = React.useMemo(() => { const snapshot = snapshots[snapshotTab]; - return model && snapshot ? extendSnapshot(model.traceUrl, snapshot, shouldPopulateCanvasFromScreenshot) : { snapshotInfoUrl: undefined, snapshotUrl: undefined, popoutUrl: undefined }; + return model && snapshot ? extendSnapshot(model.traceUri, snapshot, shouldPopulateCanvasFromScreenshot) : { snapshotInfoUrl: undefined, snapshotUrl: undefined, popoutUrl: undefined }; }, [snapshots, snapshotTab, shouldPopulateCanvasFromScreenshot, model]); const snapshotUrls = React.useMemo((): SnapshotUrls | undefined => snapshotInfoUrl !== undefined ? { snapshotInfoUrl, snapshotUrl, popoutUrl } : undefined, [snapshotInfoUrl, snapshotUrl, popoutUrl]); @@ -416,9 +416,9 @@ export function collectSnapshots(action: ActionTraceEvent | undefined): Snapshot const isUnderTest = new URLSearchParams(window.location.search).has('isUnderTest'); -export function extendSnapshot(traceUrl: string, snapshot: Snapshot, shouldPopulateCanvasFromScreenshot: boolean): SnapshotUrls { +export function extendSnapshot(traceUri: string, snapshot: Snapshot, shouldPopulateCanvasFromScreenshot: boolean): SnapshotUrls { const params = new URLSearchParams(); - params.set('trace', traceUrl); + params.set('trace', traceUri); params.set('name', snapshot.snapshotName); if (isUnderTest) params.set('isUnderTest', 'true'); @@ -436,7 +436,7 @@ export function extendSnapshot(traceUrl: string, snapshot: Snapshot, shouldPopul const popoutParams = new URLSearchParams(); popoutParams.set('r', snapshotUrl); - popoutParams.set('trace', traceUrl); + popoutParams.set('trace', traceUri); const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString(); return { snapshotInfoUrl, snapshotUrl, popoutUrl }; } diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index a1fb1bd0a31ab..3b01c4fb7914c 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -59,7 +59,7 @@ function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sour if (!response || response.status === 404) response = await fetch(`file?path=${encodeURIComponent(file)}`); if (response.status >= 400) - source.content = ``; + source.content = ``; else source.content = await response.text(); } catch { diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 9f618d892343c..1135417741629 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -54,7 +54,7 @@ export const TraceView: React.FC<{ // Test finished. const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); if (attachment && attachment.path) { - loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false })); + loadSingleTraceFile(attachment.path, result.startTime.getTime()).then(model => setModel({ model, isLive: false })); return; } @@ -65,14 +65,14 @@ export const TraceView: React.FC<{ const traceLocation = [ outputDir, - artifactsFolderName(result!.workerIndex), + artifactsFolderName(result.workerIndex), 'traces', `${item.testCase?.id}.json` ].join(pathSeparator); // Start polling running test. pollTimer.current = setTimeout(async () => { try { - const model = await loadSingleTraceFile(traceLocation); + const model = await loadSingleTraceFile(traceLocation, Date.now()); setModel({ model, isLive: true }); } catch { const model = new TraceModel('', []); @@ -110,10 +110,11 @@ const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefi return undefined; }; -async function loadSingleTraceFile(url: string): Promise { +async function loadSingleTraceFile(absolutePath: string, timestamp: number): Promise { + const traceUri = `file?path=${encodeURIComponent(absolutePath)}×tamp=${timestamp}`; const params = new URLSearchParams(); - params.set('trace', url); + params.set('trace', traceUri); const response = await fetch(`contexts?${params.toString()}`); const contextEntries = await response.json() as ContextEntry[]; - return new TraceModel(url, contextEntries); + return new TraceModel(traceUri, contextEntries); } diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 1475eee86175c..edee018ff3ca0 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -63,7 +63,7 @@ export type WorkbenchProps = { }; export const Workbench: React.FunctionComponent = props => { - const partition = props.model?.traceUrl ?? 'default'; + const partition = traceUriToPartition(props.model?.traceUri); return ; @@ -441,3 +441,11 @@ const ActionsFilterButton: React.FC<{ counters?: Map; hiddenActi /> ; }; + +function traceUriToPartition(traceUri: string | undefined): string { + if (!traceUri) + return 'default'; + const url = new URL(traceUri, 'http://localhost'); + url.searchParams.delete('timestamp'); + return url.toString(); +} diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index 3ce7611aa7c9e..90b697dddddb7 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -84,6 +84,7 @@ test('should show console messages for test', async ({ runUITest }, testInfo) => test('print', async ({ page }) => { await page.evaluate(() => console.log('page message')); console.log('node message'); + await page.waitForTimeout(500); await page.evaluate(() => console.error('page error')); console.error('node error'); console.log('Colors: \x1b[31mRED\x1b[0m \x1b[32mGREEN\x1b[0m'); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index fe31875767f9d..59bbede8d4afb 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -792,3 +792,38 @@ test('should partition action tree state by test', async ({ runUITest }) => { - treeitem /Fixture \"context\"/ `); }); + +test('should update state on subsequent run', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test1', async ({ page }) => { + await page.evaluate('1+1'); + }); + `, + }); + const actionsTree = page.getByTestId('actions-tree'); + + await page.getByTestId('test-tree').getByText('test1').click(); + await page.keyboard.press('Enter'); + + await expect(actionsTree).toMatchAriaSnapshot(` + - treeitem /Evaluate/ [selected] + `); + + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test1', async ({ page }) => { + expect(1).toBe(2); + await page.evaluate('1+1'); + }); + `, + }); + + await page.keyboard.press('Enter'); + + await expect(actionsTree).toMatchAriaSnapshot(` + - treeitem /Expect \"toBe\"/ + `); +}); diff --git a/utils/limits.sh b/utils/limits.sh new file mode 100755 index 0000000000000..71baec19a4b8f --- /dev/null +++ b/utils/limits.sh @@ -0,0 +1,2 @@ +#!/bin/sh +sudo sysctl -w fs.inotify.max_user_instances=1024