/**
 * 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 fs from 'fs';
import os from 'os';
import path from 'path';
import { getMetainfo } from '@isomorphic/protocolMetainfo';
import { assert } from '@isomorphic/assert';
import { monotonicTime } from '@isomorphic/time';
import { ManualPromise } from '@isomorphic/manualPromise';
import { eventsHelper } from '@utils/eventsHelper';
import { createGuid } from '@utils/crypto';
import { removeFolders } from '@utils/fileUtils';
import { SerializedFS } from '@utils/serializedFS';
import { getPlaywrightVersion } from '../../userAgent';
import { Snapshotter } from './snapshotter';
import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext';
import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors';
import { HarRecorder } from '../../har/harRecorder';
import { HarTracer } from '../../har/harTracer';
import { SdkObject } from '../../instrumentation';
import { isAbortError, nullProgress } from '../../progress';
const version = 8;
export class Tracing extends SdkObject {
    _fs = new SerializedFS();
    _snapshotter;
    _harTracer;
    _screencastListeners = [];
    _pageTracingRecorders = new Map();
    _eventListeners = [];
    _context;
    // Note: state should only be touched inside API methods, but not inside trace operations.
    _state;
    _isStopping = false;
    _precreatedTracesDir;
    _tracesTmpDir;
    _allResources = new Set();
    _contextCreatedEvent;
    _pendingHarEntries = new Set();
    _started = false;
    harRecorders = new Map();
    constructor(context, tracesDir) {
        super(context, 'tracing');
        this._context = context;
        this._precreatedTracesDir = tracesDir;
        this._harTracer = new HarTracer(context, null, this, {
            content: 'attach',
            includeTraceInfo: true,
            recordRequestOverrides: false,
            waitForContentOnStop: false,
        });
        const testIdAttributeName = ('selectors' in context) ? context.selectors().testIdAttributeName() : undefined;
        this._contextCreatedEvent = {
            version,
            type: 'context-options',
            origin: 'library',
            browserName: '',
            playwrightVersion: getPlaywrightVersion(),
            options: {},
            platform: process.platform,
            wallTime: 0,
            monotonicTime: 0,
            sdkLanguage: this._sdkLanguage(),
            testIdAttributeName,
            contextId: context.guid,
        };
        if (context instanceof BrowserContext) {
            this._snapshotter = new Snapshotter(context, this);
            assert(tracesDir, 'tracesDir must be specified for BrowserContext');
            this._contextCreatedEvent.browserName = context._browser.options.name;
            this._contextCreatedEvent.channel = context._browser.options.channel;
            this._contextCreatedEvent.options = context._options;
        }
    }
    _sdkLanguage() {
        return this._context instanceof BrowserContext ? this._context._browser.sdkLanguage() : this._context.attribution.playwright.options.sdkLanguage;
    }
    async resetForReuse(progress) {
        // Discard previous chunk if any and ignore any errors there.
        await this.stopChunk(progress, { mode: 'discard' }).catch(() => { });
        await progress.race(this._stop());
        if (this._snapshotter)
            await progress.race(this._snapshotter.resetForReuse());
    }
    start(progress, options) {
        if (this._isStopping)
            throw new Error('Cannot start tracing while stopping');
        if (this._state)
            throw new Error('Tracing has been already started');
        // Re-write for testing.
        this._contextCreatedEvent.sdkLanguage = this._sdkLanguage();
        // TODO: passing the same name for two contexts makes them write into a single file
        // and conflict.
        const traceName = options.name || createGuid();
        const tracesDir = this._createTracesDirIfNeeded();
        // Init the state synchronously.
        this._state = {
            options,
            traceName,
            tracesDir,
            traceFile: path.join(tracesDir, traceName + '.trace'),
            networkFile: path.join(tracesDir, traceName + '.network'),
            resourcesDir: path.join(tracesDir, 'resources'),
            chunkOrdinal: 0,
            traceSha1s: new Set(),
            networkSha1s: new Set(),
            recording: false,
            callIds: new Set(),
            groupStack: [],
        };
        this._fs.mkdir(this._state.resourcesDir);
        this._fs.writeFile(this._state.networkFile, '');
        // Tracing is 10x bigger if we include scripts in every trace.
        if (options.snapshots)
            this._harTracer.start({ omitScripts: !options.live });
        this._started = true;
    }
    async startChunk(progress, options = {}) {
        if (this._state && this._state.recording)
            await this.stopChunk(progress, { mode: 'discard' });
        if (!this._state)
            throw new Error('Must start tracing before starting a new chunk');
        if (this._isStopping)
            throw new Error('Cannot start a trace chunk while stopping');
        this._state.recording = true;
        this._state.callIds.clear();
        // - Browser context network trace is shared across chunks as it contains resources
        // used to serve page snapshots, so make a copy with the new name.
        // - APIRequestContext network traces are chunk-specific, always start from scratch.
        const preserveNetworkResources = this._context instanceof BrowserContext;
        if (options.name && options.name !== this._state.traceName)
            this._changeTraceName(this._state, options.name, preserveNetworkResources);
        else
            this._allocateNewTraceFile(this._state);
        if (!preserveNetworkResources)
            this._fs.writeFile(this._state.networkFile, '');
        this._fs.mkdir(path.dirname(this._state.traceFile));
        const event = {
            ...this._contextCreatedEvent,
            title: options.title,
            wallTime: Date.now(),
            monotonicTime: monotonicTime()
        };
        this._appendTraceEvent(event);
        this._context.instrumentation.addListener(this, this._context);
        this._eventListeners.push(eventsHelper.addEventListener(this._context, BrowserContext.Events.Console, this._onConsoleMessage.bind(this)), eventsHelper.addEventListener(this._context, BrowserContext.Events.PageError, this._onPageError.bind(this)));
        if (this._state.options.screenshots)
            this._startScreencast();
        if (this._state.options.snapshots)
            await this._snapshotter?.start(progress);
        return { traceName: this._state.traceName };
    }
    _currentGroupId() {
        return this._state?.groupStack.length ? this._state.groupStack[this._state.groupStack.length - 1] : undefined;
    }
    group(progress, name, location) {
        if (!this._state)
            return;
        const metadata = progress.metadata;
        const stackFrames = [];
        const { file, line, column } = location ?? metadata.location ?? {};
        if (file) {
            stackFrames.push({
                file,
                line: line ?? 0,
                column: column ?? 0,
            });
        }
        const event = {
            type: 'before',
            callId: metadata.id,
            startTime: metadata.startTime,
            title: name,
            class: 'Tracing',
            method: 'tracingGroup',
            params: {},
            stepId: metadata.stepId,
            stack: stackFrames,
        };
        if (this._currentGroupId())
            event.parentId = this._currentGroupId();
        this._state.groupStack.push(event.callId);
        this._appendTraceEvent(event);
    }
    groupEnd(progress) {
        this._groupEnd();
    }
    _groupEnd() {
        if (!this._state)
            return;
        const callId = this._state.groupStack.pop();
        if (!callId)
            return;
        const event = {
            type: 'after',
            callId,
            endTime: monotonicTime(),
        };
        this._appendTraceEvent(event);
    }
    _startScreencast() {
        if (!(this._context instanceof BrowserContext))
            return;
        for (const page of this._context.pages())
            this._startScreencastInPage(page);
        this._screencastListeners.push(eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, this._startScreencastInPage.bind(this)));
    }
    _stopScreencast() {
        eventsHelper.removeEventListeners(this._screencastListeners);
        for (const recorder of this._pageTracingRecorders.values())
            recorder.dispose();
        this._pageTracingRecorders.clear();
    }
    _allocateNewTraceFile(state) {
        const suffix = state.chunkOrdinal ? `-chunk${state.chunkOrdinal}` : ``;
        state.chunkOrdinal++;
        state.traceFile = path.join(state.tracesDir, `${state.traceName}${suffix}.trace`);
    }
    _changeTraceName(state, name, preserveNetworkResources) {
        state.traceName = name;
        state.chunkOrdinal = 0; // Reset ordinal for the new name.
        this._allocateNewTraceFile(state);
        const newNetworkFile = path.join(state.tracesDir, name + '.network');
        if (preserveNetworkResources)
            this._fs.copyFile(state.networkFile, newNetworkFile);
        state.networkFile = newNetworkFile;
    }
    async stop(progress) {
        await progress.race(this._stop());
    }
    async _stop() {
        if (!this._state)
            return;
        if (this._isStopping)
            throw new Error(`Tracing is already stopping`);
        if (this._state.recording)
            throw new Error(`Must stop trace file before stopping tracing`);
        this._closeAllGroups();
        this._harTracer.stop();
        this.flushHarEntries();
        await this._fs.syncAndGetError().finally(() => {
            this._state = undefined;
        });
    }
    async deleteTmpTracesDir() {
        if (this._tracesTmpDir)
            await removeFolders([this._tracesTmpDir]);
    }
    _createTracesDirIfNeeded() {
        if (this._precreatedTracesDir)
            return this._precreatedTracesDir;
        this._tracesTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-tracing-'));
        return this._tracesTmpDir;
    }
    abort() {
        this._snapshotter?.dispose();
        this._harTracer.stop();
    }
    async flush() {
        this.abort();
        for (const harRecorder of this.harRecorders.values())
            await harRecorder.flush();
        await this._fs.syncAndGetError();
    }
    harStart(page, options) {
        const harId = createGuid();
        const artifactsDir = this._context instanceof BrowserContext ? this._context._browser.options.artifactsDir : this._createTracesDirIfNeeded();
        this.harRecorders.set(harId, new HarRecorder(this._context, artifactsDir, harId, page, options));
        return harId;
    }
    async harExport(progress, harId, mode) {
        const recorder = this.harRecorders.get(harId || '');
        const result = await progress.race(recorder.export(mode));
        this.harRecorders.delete(harId || '');
        return result;
    }
    _closeAllGroups() {
        while (this._currentGroupId())
            this._groupEnd();
    }
    async stopChunk(progress, params) {
        if (this._isStopping)
            throw new Error(`Tracing is already stopping`);
        this._isStopping = true;
        if (!this._state || !this._state.recording) {
            this._isStopping = false;
            if (params.mode !== 'discard')
                throw new Error(`Must start tracing before stopping`);
            return {};
        }
        this._closeAllGroups();
        this._context.instrumentation.removeListener(this);
        eventsHelper.removeEventListeners(this._eventListeners);
        if (this._state.options.screenshots)
            this._stopScreencast();
        if (this._state.options.snapshots)
            this._snapshotter?.stop();
        this.flushHarEntries();
        // Network file survives across chunks, make a snapshot before returning the resulting entries.
        // We should pick a name starting with "traceName" and ending with .network.
        // Something like <traceName>someSuffixHere.network.
        // However, this name must not clash with any other "traceName".network in the same tracesDir.
        // We can use <traceName>-<guid>.network, but "-pwnetcopy-0" suffix is more readable
        // and makes it easier to debug future issues.
        const newNetworkFile = path.join(this._state.tracesDir, this._state.traceName + `-pwnetcopy-${this._state.chunkOrdinal}.network`);
        const entries = [];
        entries.push({ name: 'trace.trace', value: this._state.traceFile });
        entries.push({ name: 'trace.network', value: newNetworkFile });
        for (const sha1 of new Set([...this._state.traceSha1s, ...this._state.networkSha1s]))
            entries.push({ name: path.join('resources', sha1), value: path.join(this._state.resourcesDir, sha1) });
        // Only reset trace sha1s, network resources are preserved between chunks.
        this._state.traceSha1s = new Set();
        if (params.mode === 'discard') {
            this._isStopping = false;
            this._state.recording = false;
            return {};
        }
        this._fs.copyFile(this._state.networkFile, newNetworkFile);
        const zipFileName = this._state.traceFile + '.zip';
        if (params.mode === 'archive')
            this._fs.zip(entries, zipFileName);
        // Make sure all file operations complete.
        let error;
        try {
            await progress.race(this._fs.syncAndGetError());
        }
        catch (e) {
            error = e;
        }
        this._isStopping = false;
        if (this._state)
            this._state.recording = false;
        // IMPORTANT: no awaits after this point, to make sure recording state is correct.
        if (error) {
            // This check is here because closing the browser removes the tracesDir and tracing
            // cannot access removed files. Clients are ready for the missing artifact.
            if (!isAbortError(error) && this._context instanceof BrowserContext && !this._context._browser.isConnected())
                return {};
            throw error;
        }
        if (params.mode === 'entries')
            return { entries };
        const artifact = new Artifact(this._context, zipFileName);
        artifact.reportFinished();
        return { artifact };
    }
    async _captureSnapshot(snapshotName, sdkObject, metadata) {
        if (!snapshotName || !sdkObject.attribution.page)
            return;
        await this._snapshotter?.captureSnapshot(sdkObject.attribution.page, metadata.id, snapshotName).catch(() => { });
    }
    _shouldCaptureSnapshot(sdkObject, metadata, phase) {
        if (!sdkObject.attribution.page || !this._snapshotter?.started())
            return;
        const metainfo = getMetainfo(metadata);
        if (!metainfo?.snapshot)
            return false;
        switch (phase) {
            case 'before': return !metainfo.input || !!metainfo.isAutoWaiting;
            case 'input': return !!metainfo.input;
            case 'after': return true;
        }
    }
    onBeforeCall(sdkObject, metadata, parentId) {
        // IMPORTANT: no awaits in this method, this._appendTraceEvent must be called synchronously.
        const event = createBeforeActionTraceEvent(metadata, parentId ?? this._currentGroupId());
        if (!event)
            return Promise.resolve();
        this._temporarilyDisableThrottling(sdkObject.attribution.page);
        if (this._shouldCaptureSnapshot(sdkObject, metadata, 'before'))
            event.beforeSnapshot = `before@${metadata.id}`;
        this._state?.callIds.add(metadata.id);
        this._appendTraceEvent(event);
        return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata);
    }
    onBeforeInputAction(sdkObject, metadata) {
        // IMPORTANT: no awaits in this method, this._appendTraceEvent must be called synchronously.
        if (!this._state?.callIds.has(metadata.id))
            return Promise.resolve();
        const event = createInputActionTraceEvent(metadata);
        if (!event)
            return Promise.resolve();
        this._temporarilyDisableThrottling(sdkObject.attribution.page);
        if (this._shouldCaptureSnapshot(sdkObject, metadata, 'input'))
            event.inputSnapshot = `input@${metadata.id}`;
        this._appendTraceEvent(event);
        return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata);
    }
    onCallLog(sdkObject, metadata, logName, message) {
        if (!this._state?.callIds.has(metadata.id))
            return;
        if (metadata.internal)
            return;
        if (logName !== 'api')
            return;
        const event = createActionLogTraceEvent(metadata, message);
        if (event)
            this._appendTraceEvent(event);
    }
    onAfterCall(sdkObject, metadata) {
        // IMPORTANT: no awaits in this method, this._appendTraceEvent must be called synchronously.
        if (!this._state?.callIds.has(metadata.id))
            return Promise.resolve();
        this._state?.callIds.delete(metadata.id);
        const event = createAfterActionTraceEvent(metadata);
        if (!event)
            return Promise.resolve();
        this._temporarilyDisableThrottling(sdkObject.attribution.page);
        if (this._shouldCaptureSnapshot(sdkObject, metadata, 'after'))
            event.afterSnapshot = `after@${metadata.id}`;
        this._appendTraceEvent(event);
        return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata);
    }
    onEntryStarted(entry) {
        this._pendingHarEntries.add(entry);
    }
    onEntryFinished(entry) {
        this._pendingHarEntries.delete(entry);
        const event = { type: 'resource-snapshot', snapshot: entry };
        const visited = visitTraceEvent(event, this._state.networkSha1s);
        this._fs.appendFile(this._state.networkFile, JSON.stringify(visited) + '\n', true /* flush */);
    }
    flushHarEntries() {
        const harLines = [];
        for (const entry of this._pendingHarEntries) {
            const event = { type: 'resource-snapshot', snapshot: entry };
            const visited = visitTraceEvent(event, this._state.networkSha1s);
            harLines.push(JSON.stringify(visited));
        }
        this._pendingHarEntries.clear();
        if (harLines.length)
            this._fs.appendFile(this._state.networkFile, harLines.join('\n') + '\n', true /* flush */);
    }
    onContentBlob(sha1, buffer) {
        this._appendResource(sha1, buffer);
    }
    onSnapshotterBlob(blob) {
        this._appendResource(blob.sha1, blob.buffer);
    }
    onFrameSnapshot(snapshot) {
        this._appendTraceEvent({ type: 'frame-snapshot', snapshot });
    }
    _onConsoleMessage(message) {
        const event = {
            type: 'console',
            messageType: message.type(),
            text: message.text(),
            args: message.args().map(a => ({ preview: a.toString(), value: a.rawValue() })),
            location: message.location(),
            time: monotonicTime(),
            pageId: message.page()?.guid,
        };
        this._appendTraceEvent(event);
    }
    onDialog(dialog) {
        const event = {
            type: 'event',
            time: monotonicTime(),
            class: 'BrowserContext',
            method: 'dialog',
            params: { pageId: dialog.page().guid, type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() },
        };
        this._appendTraceEvent(event);
    }
    onDownload(page, download) {
        const event = {
            type: 'event',
            time: monotonicTime(),
            class: 'BrowserContext',
            method: 'download',
            params: { pageId: page.guid, url: download.url, suggestedFilename: download.suggestedFilename() },
        };
        this._appendTraceEvent(event);
    }
    onPageOpen(page) {
        const event = {
            type: 'event',
            time: monotonicTime(),
            class: 'BrowserContext',
            method: 'page',
            params: { pageId: page.guid, openerPageId: page.opener()?.guid },
        };
        this._appendTraceEvent(event);
    }
    onPageClose(page) {
        const event = {
            type: 'event',
            time: monotonicTime(),
            class: 'BrowserContext',
            method: 'pageClosed',
            params: { pageId: page.guid },
        };
        this._appendTraceEvent(event);
    }
    dispose(params) {
        // Avoid protocol calls for the closed context.
        if (this._started)
            this.stopChunk(nullProgress, params).then(() => this._stop()).catch(() => { });
        this._started = false;
    }
    _onPageError(pageError, page) {
        const event = {
            type: 'event',
            time: monotonicTime(),
            class: 'BrowserContext',
            method: 'pageError',
            params: {
                error: serializeError(pageError.error),
                location: {
                    url: pageError.location.url,
                    line: pageError.location.lineNumber,
                    column: pageError.location.columnNumber,
                },
            },
            pageId: page.guid,
        };
        this._appendTraceEvent(event);
    }
    _temporarilyDisableThrottling(page) {
        if (page)
            this._pageTracingRecorders.get(page)?.temporarilyDisableThrottling();
    }
    _startScreencastInPage(page) {
        const prefix = page.guid;
        const onFrame = (params) => {
            const suffix = Date.now();
            const sha1 = `${prefix}-${suffix}.jpeg`;
            const event = {
                type: 'screencast-frame',
                pageId: page.guid,
                sha1,
                width: params.viewportWidth,
                height: params.viewportHeight,
                timestamp: monotonicTime(),
                frameSwapWallTime: params.frameSwapWallTime,
            };
            // Make sure to write the screencast frame before adding a reference to it.
            this._appendResource(sha1, params.buffer);
            this._appendTraceEvent(event);
        };
        this._pageTracingRecorders.set(page, new ScreencastTracingRecorder(page.screencast, onFrame));
    }
    _appendTraceEvent(event) {
        const visited = visitTraceEvent(event, this._state.traceSha1s);
        // Do not flush (console) events, they are too noisy, unless we are in ui mode (live).
        const flush = this._state.options.live || (event.type !== 'event' && event.type !== 'console' && event.type !== 'log');
        this._fs.appendFile(this._state.traceFile, JSON.stringify(visited) + '\n', flush);
    }
    _appendResource(sha1, buffer) {
        if (this._allResources.has(sha1))
            return;
        this._allResources.add(sha1);
        const resourcePath = path.join(this._state.resourcesDir, sha1);
        this._fs.writeFile(resourcePath, buffer, true /* skipIfExists */);
    }
}
function visitTraceEvent(object, sha1s) {
    if (Array.isArray(object))
        return object.map(o => visitTraceEvent(o, sha1s));
    if (object instanceof Dispatcher)
        return `<${object._type}>`;
    if (object instanceof Buffer)
        return `<Buffer>`;
    if (object instanceof Date)
        return object;
    if (typeof object === 'object') {
        const result = {};
        for (const key in object) {
            if (key === 'sha1' || key === '_sha1' || key.endsWith('Sha1')) {
                const sha1 = object[key];
                if (sha1)
                    sha1s.add(sha1);
            }
            result[key] = visitTraceEvent(object[key], sha1s);
        }
        return result;
    }
    return object;
}
function createBeforeActionTraceEvent(metadata, parentId) {
    if (metadata.internal || metadata.method.startsWith('tracing'))
        return null;
    const event = {
        type: 'before',
        callId: metadata.id,
        startTime: metadata.startTime,
        title: metadata.title,
        class: metadata.type,
        method: metadata.method,
        params: metadata.params,
        stepId: metadata.stepId,
        pageId: metadata.pageId,
    };
    if (parentId)
        event.parentId = parentId;
    return event;
}
function createInputActionTraceEvent(metadata) {
    if (metadata.internal || metadata.method.startsWith('tracing'))
        return null;
    return {
        type: 'input',
        callId: metadata.id,
        point: metadata.point,
    };
}
function createActionLogTraceEvent(metadata, message) {
    if (metadata.internal || metadata.method.startsWith('tracing'))
        return null;
    return {
        type: 'log',
        callId: metadata.id,
        time: monotonicTime(),
        message,
    };
}
function createAfterActionTraceEvent(metadata) {
    if (metadata.internal || metadata.method.startsWith('tracing'))
        return null;
    return {
        type: 'after',
        callId: metadata.id,
        endTime: metadata.endTime,
        error: metadata.error?.error,
        result: metadata.result,
        point: metadata.point,
    };
}
const throttledRate = 200;
const unthrottleDuration = 500;
class ScreencastTracingRecorder {
    _screencast;
    _client;
    _unthrottledUntil = 0;
    _pendingAck;
    _timer;
    constructor(screencast, onFrame) {
        this._screencast = screencast;
        this._client = {
            onFrame: (frame) => {
                const time = monotonicTime();
                if (time < this._unthrottledUntil) {
                    onFrame(frame);
                    return;
                }
                // We are throttling, but frames are coming => there is another client.
                if (this._pendingAck)
                    return;
                onFrame(frame);
                this._pendingAck = new ManualPromise();
                this._timer = setTimeout(() => this._clearPendingAck(), throttledRate);
                return this._pendingAck;
            },
            gracefulClose: () => this.dispose(),
            dispose: () => this.dispose(),
        };
        this._screencast.addClient(this._client);
    }
    dispose() {
        this._screencast.removeClient(this._client);
        this._clearPendingAck();
    }
    temporarilyDisableThrottling() {
        this._unthrottledUntil = monotonicTime() + unthrottleDuration;
        this._clearPendingAck();
    }
    _clearPendingAck() {
        this._pendingAck?.resolve();
        this._pendingAck = undefined;
        if (this._timer) {
            clearTimeout(this._timer);
            this._timer = undefined;
        }
    }
}
//# sourceMappingURL=tracing.js.map