/**
 * 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 { jpegjs } from 'playwright-core/lib/utilsBundle';
import path from 'path';
import { browserTest, contextTest as test, expect } from '../config/browserTest';
import { parseTraceRaw } from '../config/utils';
import type { StackFrame } from '@protocol/channels';
import type { ActionTraceEvent } from '../../packages/trace/src/trace';
import { artifactsFolderName } from '../../packages/playwright/src/isomorphic/folders';
import { rafraf } from '../page/pageTest';

test.skip(({ trace }) => trace === 'on');

test('should collect trace with resources, but no js', async ({ context, page, server, asset }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.goto(server.PREFIX + '/frames/frame.html');
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  await page.mouse.move(20, 20);
  await page.mouse.dblclick(30, 30);
  await page.keyboard.insertText('abc');
  await page.goto(server.PREFIX + '/input/fileupload.html');
  await page.locator('input[type="file"]').setInputFiles(asset('file-to-upload.txt'));
  await page.waitForTimeout(2000);  // Give it some time to produce screenshots.
  await page.close();
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });

  const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(events[0].type).toBe('context-options');
  expect(actions).toEqual([
    'Navigate to "/frames/frame.html"',
    'Set content',
    'Click',
    'Mouse move',
    'Double click',
    'Insert "abc"',
    'Navigate to "/input/fileupload.html"',
    'Set input files',
    'Wait for timeout',
    'Close page',
  ]);

  expect(events.some(e => e.type === 'frame-snapshot')).toBeTruthy();
  expect(events.some(e => e.type === 'screencast-frame')).toBeTruthy();
  const style = events.find(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'));
  expect(style).toBeTruthy();
  expect(style.snapshot.response.content._sha1).toBeTruthy();
  const script = events.find(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('script.js'));
  expect(script).toBeTruthy();
  expect(script.snapshot.response.content._sha1).toBe(undefined);
});

test('should use the correct title for event driven callbacks', async ({ context, page, server }, testInfo) => {
  await context.tracing.start();
  // route.* calls should not be included in the trace
  await page.route('**/empty.html', route => route.continue());
  await page.goto(server.PREFIX + '/empty.html');
  await page.goto(server.PREFIX + '/grid.html');

  // The default internal dialog handler should not provide an action.
  await page.evaluate(() => alert('yo'));
  await page.reload();
  // now we do it again with a dialog event listener attached which should produce an action.
  page.on('dialog', dialog => {
    void dialog.accept('answer!');
  });
  await page.evaluate(() => alert('yo'));

  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
  const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(events[0].type).toBe('context-options');
  expect(actions).toEqual([
    'Route requests',
    'Navigate to "/empty.html"',
    'Continue request',
    'Navigate to "/grid.html"',
    'Evaluate',
    'Reload',
    'Evaluate',
    'Accept dialog',
  ]);
});

test('should not collect snapshots by default', async ({ context, page, server }, testInfo) => {
  await context.tracing.start();
  await page.goto(server.EMPTY_PAGE);
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  await page.close();
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });

  const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy();
  expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy();
});

test('can call tracing.group/groupEnd at any time and auto-close', async ({ context, page, server }, testInfo) => {
  await context.tracing.group('ignored');
  await context.tracing.groupEnd();
  await context.tracing.group('ignored2');

  await context.tracing.start();
  await context.tracing.group('actual');
  await page.goto(server.EMPTY_PAGE);
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });

  await context.tracing.group('ignored3');
  await context.tracing.groupEnd();
  await context.tracing.groupEnd();
  await context.tracing.groupEnd();

  const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  const groups = events.filter(e => e.method === 'tracingGroup');
  expect(groups).toHaveLength(1);
  expect(groups[0].title).toBe('actual');
  expect(events.some(e => e.type === 'after' && e.callId === groups[0].callId)).toBe(true);
});

test('should not include buffers in the trace', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ snapshots: true });
  await page.goto(server.PREFIX + '/empty.html');
  await page.screenshot();
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
  const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  const screenshotEvent = actionObjects.find(a => a.method === 'screenshot');
  expect(screenshotEvent.beforeSnapshot).toBeTruthy();
  expect(screenshotEvent.afterSnapshot).toBeTruthy();
  expect(screenshotEvent.result).toEqual({
    'binary': '<Buffer>',
  });
});

test('should exclude internal pages', async ({ browserName, context, page, server }, testInfo) => {
  await page.goto(server.EMPTY_PAGE);

  await context.tracing.start();
  await context.storageState();
  await page.close();
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });

  const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  const pageIds = new Set();
  trace.events.forEach(e => {
    const pageId = e.pageId;
    if (pageId)
      pageIds.add(pageId);
  });
  expect(pageIds.size).toBe(1);
});

test('should include context API requests', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ snapshots: true });
  await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } });
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
  const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(actions).toContain('POST "/simple.json"');
  const harEntry = events.find(e => e.type === 'resource-snapshot');
  expect(harEntry).toBeTruthy();
  expect(harEntry.snapshot.request.url).toBe(server.PREFIX + '/simple.json');
  expect(harEntry.snapshot.response.status).toBe(200);
});

test('should collect two traces', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.goto(server.EMPTY_PAGE);
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });

  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.dblclick('"Click"');
  await page.close();
  await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') });

  {
    const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
    expect(events[0].type).toBe('context-options');
    expect(actions).toEqual([
      'Navigate to "/empty.html"',
      'Set content',
      'Click',
    ]);
  }

  {
    const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
    expect(events[0].type).toBe('context-options');
    expect(actions).toEqual([
      'Double click',
      'Close page',
    ]);
  }
});

test('should respect tracesDir and name', async ({ browserType, server, mode }, testInfo) => {
  test.skip(mode.startsWith('service'), 'Service ignores tracesDir');

  const tracesDir = testInfo.outputPath('traces');
  const browser = await browserType.launch({ tracesDir });
  const context = await browser.newContext();
  const page = await context.newPage();

  await context.tracing.start({ name: 'name1', snapshots: true });
  await page.goto(server.PREFIX + '/one-style.html');
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace1.zip') });
  expect(fs.existsSync(path.join(tracesDir, 'name1.trace'))).toBe(true);
  expect(fs.existsSync(path.join(tracesDir, 'name1.network'))).toBe(true);

  await context.tracing.startChunk({ name: 'name2' });
  await page.goto(server.PREFIX + '/har.html');
  await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') });
  expect(fs.existsSync(path.join(tracesDir, 'name2.trace'))).toBe(true);
  expect(fs.existsSync(path.join(tracesDir, 'name2.network'))).toBe(true);

  await browser.close();

  function resourceNames(resources: Map<string, Buffer>) {
    return [...resources.keys()].map(file => {
      return file.replace(/^resources\/.*\.(html|css)$/, 'resources/XXX.$1');
    }).sort();
  }

  {
    const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
    expect(actions).toEqual(['Navigate to "/one-style.html"']);
    expect(resourceNames(resources)).toEqual([
      'resources/XXX.css',
      'resources/XXX.html',
      'trace.network',
      'trace.stacks',
      'trace.trace',
    ]);
  }

  {
    const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
    expect(actions).toEqual(['Navigate to "/har.html"']);
    expect(resourceNames(resources)).toEqual([
      'resources/XXX.css',
      'resources/XXX.html',
      'resources/XXX.html',
      'trace.network',
      'trace.stacks',
      'trace.trace',
    ]);
  }
});

test('should not include trace resources from the previous chunks', async ({ context, page, server, browserName, mode }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });

  await context.tracing.startChunk();
  await page.goto(server.EMPTY_PAGE);
  await page.setContent(`
    <style>
      @keyframes move {
        from { marign-left: 0; }
        to   { margin-left: 1000px; }
      }
      button {
        animation: 20s linear move;
        animation-iteration-count: infinite;
      }
    </style>
    <button>Click</button>
  `);
  await page.click('"Click"', { force: true });
  // Give it enough time for both screenshots to get into the trace.
  await new Promise(f => setTimeout(f, 3000));
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace1.zip') });

  await context.tracing.startChunk();
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace2.zip') });

  let jpegs: string[] = [];
  {
    const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
    const names = Array.from(resources.keys());
    expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
    jpegs = names.filter(n => n.endsWith('.jpeg'));
    expect(jpegs.length).toBeGreaterThan(0);
    // 1 source file for the test.
    expect(names.filter(n => n.endsWith('.txt')).length).toBe(1);
  }

  {
    const { resources } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
    const names = Array.from(resources.keys());
    // 1 network resource should be preserved.
    expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
    // screenshots from the previous chunk should not be preserved.
    expect(names.filter(n => jpegs.includes(n)).length).toBe(0);
    // 0 source files for the second test.
    expect(names.filter(n => n.endsWith('.txt')).length).toBe(0);
  }
});

test('should overwrite existing file', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
  await page.goto(server.EMPTY_PAGE);
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  const path = testInfo.outputPath('trace1.zip');
  await context.tracing.stop({ path });
  {
    const { resources } = await parseTraceRaw(path);
    const names = Array.from(resources.keys());
    expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
  }

  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
  await context.tracing.stop({ path });

  {
    const { resources } = await parseTraceRaw(path);
    const names = Array.from(resources.keys());
    expect(names.filter(n => n.endsWith('.html')).length).toBe(0);
  }
});

test('should collect sources', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ sources: true });
  await page.goto(server.EMPTY_PAGE);
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });

  const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
  const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt'));
  expect(sourceNames.length).toBe(1);
  const sourceFile = resources.get(sourceNames[0]);
  const thisFile = await fs.promises.readFile(__filename);
  expect(sourceFile).toEqual(thisFile);
});

test('should record network failures', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ snapshots: true });
  await page.route('**/*', route => route.abort('connectionaborted'));
  await page.goto(server.EMPTY_PAGE).catch(e => {});
  await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });

  const { events } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
  const requestEvent = events.find(e => e.type === 'resource-snapshot' && !!e.snapshot.response._failureText);
  expect(requestEvent).toBeTruthy();
});

test('should not crash when browser closes mid-trace', async ({ browserType, server }, testInfo) => {
  const browser = await browserType.launch();
  const page = await browser.newPage();
  await page.context().tracing.start({ snapshots: true, screenshots: true });
  await page.goto(server.EMPTY_PAGE);
  await browser.close();
  await new Promise(f => setTimeout(f, 1000));  // Give it some time to throw errors
});

test('should survive browser.close with auto-created traces dir', async ({ browserType }, testInfo) => {
  const oldTracesDir = (browserType as any)._playwright._defaultTracesDir;
  (browserType as any)._playwright._defaultTracesDir = undefined;
  const browser = await browserType.launch();
  const page = await browser.newPage();
  await page.context().tracing.start();

  const done = { value: false };
  async function go() {
    while (!done.value) {
      // Produce a lot of operations to make sure tracing operations are enqueued.
      for (let i = 0; i < 100; i++)
        page.evaluate('1 + 1').catch(() => {});
      await new Promise(f => setTimeout(f, 250));
    }
  }

  void go();
  await new Promise(f => setTimeout(f, 1000));

  // Close the browser and give it some time to fail.
  await Promise.all([
    browser.close(),
    new Promise(f => setTimeout(f, 500)),
  ]);

  done.value = true;
  (browserType as any)._playwright._defaultTracesDir = oldTracesDir;
});

test('should not stall on dialogs', async ({ page, context, server }) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.goto(server.EMPTY_PAGE);

  page.on('dialog', async dialog => {
    await dialog.accept();
  });

  await page.evaluate(() => {
    confirm('are you sure');
  });
  await context.tracing.stop();
});


for (const params of [
  {
    id: 'fit',
    width: 500,
    height: 500,
  },
  {
    id: 'crop',
    width: 400, // Less than 450 to test firefox
    height: 800,
  },
  {
    id: 'scale',
    width: 1024,
    height: 768,
  }
]) {
  browserTest(`should produce screencast frames ${params.id}`, async ({ video, contextFactory, browserName, platform, headless, isHeadlessShell }, testInfo) => {
    browserTest.skip(browserName === 'chromium' && video === 'on', 'Same screencast resolution conflicts');
    browserTest.fixme(browserName === 'chromium' && !isHeadlessShell, 'Chromium (but not headless-shell) screencast has a min width issue');
    browserTest.fixme(params.id === 'fit' && browserName === 'chromium' && platform === 'darwin', 'High DPI maxes image at 600x600');
    browserTest.fixme(params.id === 'fit' && browserName === 'webkit' && platform === 'linux', 'Image size is flaky');
    browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different');

    const scale = Math.min(800 / params.width, 600 / params.height, 1);
    const previewWidth = params.width * scale;
    const previewHeight = params.height * scale;

    const context = await contextFactory({ viewport: { width: params.width, height: params.height } });
    await context.tracing.start({ screenshots: true, snapshots: true });
    const page = await context.newPage();
    // Make sure we have a chance to paint.
    for (let i = 0; i < 100; ++i) {
      const percentage = (i + 1) + '%';
      await page.setContent(`<body style="box-sizing: border-box; width: 100%; height: 100%; margin:0; background: linear-gradient(to bottom, red ${percentage}, transparent ${percentage}); border: 50px solid blue"></body>`);
      await rafraf(page);
    }
    for (let i = 0; i < 10; ++i)
      await rafraf(page);
    await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });

    const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
    const frames = events.filter(e => e.type === 'screencast-frame');

    // Check all frame sizes.
    for (const frame of frames) {
      expect(frame.width).toBe(params.width);
      expect(frame.height).toBe(params.height);
      const buffer = resources.get('resources/' + frame.sha1);
      const image = jpegjs.decode(buffer);
      expect(image.width).toBe(previewWidth);
      expect(image.height).toBe(previewHeight);
    }

    const frame = frames[frames.length - 1]; // pick last frame.
    const buffer = resources.get('resources/' + frame.sha1);
    const image = jpegjs.decode(buffer);
    expect(image.data.byteLength).toBe(previewWidth * previewHeight * 4);
    expectRed(image.data, previewWidth * previewHeight * 4 / 2 + previewWidth * 4 / 2); // center is red
    expectBlue(image.data, previewWidth * 5 * 4 + previewWidth * 4 / 2); // top
    expectBlue(image.data, previewWidth * (previewHeight - 5) * 4 + previewWidth * 4 / 2); // bottom
    expectBlue(image.data, previewWidth * previewHeight * 4 / 2 + 5 * 4); // left
    expectBlue(image.data, previewWidth * previewHeight * 4 / 2 + (previewWidth - 5) * 4); // right
  });
}

test('should include interrupted actions', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.goto(server.EMPTY_PAGE);
  await page.setContent('<button>Click</button>');
  page.click('"ClickNoButton"').catch(() =>  {});
  await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
  await context.close();

  const { actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(actions).toContain('Click');
});

test('should throw when starting with different options', async ({ context }) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  const error = await context.tracing.start({ screenshots: false, snapshots: false }).catch(e => e);
  expect(error.message).toContain('Tracing has been already started');
});

test('should throw when stopping without start', async ({ context }, testInfo) => {
  const error = await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }).catch(e => e);
  expect(error.message).toContain('Must start tracing before stopping');
});

test('should not throw when stopping without start but not exporting', async ({ context }, testInfo) => {
  await context.tracing.stop();
});

test('should work with multiple chunks', async ({ context, page, server }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.goto(server.PREFIX + '/frames/frame.html');

  await context.tracing.startChunk();
  await page.setContent('<button>Click</button>');
  await page.click('"Click"');
  page.click('"ClickNoButton"', { timeout: 0 }).catch(() =>  {});
  await page.evaluate(() => {});
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });

  await context.tracing.startChunk();
  await page.hover('"Click"');
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace2.zip') });

  await context.tracing.startChunk();
  await page.click('"Click"');
  await context.tracing.stopChunk();  // Should stop without a path.

  const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(trace1.events[0].type).toBe('context-options');
  expect(trace1.actions).toEqual([
    'Set content',
    'Click',
    'Click',
    'Evaluate',
  ]);
  expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBeTruthy();
  expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy();

  const trace2 = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
  expect(trace2.events[0].type).toBe('context-options');
  expect(trace2.actions).toEqual([
    'Hover',
  ]);
  expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBeTruthy();
  expect(trace2.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy();
});

test('should export trace concurrently to second navigation', async ({ context, page, server }, testInfo) => {
  for (let timeout = 0; timeout < 200; timeout += 20) {
    await context.tracing.start({ screenshots: true, snapshots: true });
    await page.goto(server.PREFIX + '/grid.html');

    // Navigate to the same page to produce the same trace resources
    // that might be concurrently exported.
    const promise = page.goto(server.PREFIX + '/grid.html');
    await page.waitForTimeout(timeout);
    await Promise.all([
      promise,
      context.tracing.stop({ path: testInfo.outputPath('trace.zip') }),
    ]);
  }
});

test('should not hang for clicks that open dialogs', async ({ context, page }) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  const dialogPromise = page.waitForEvent('dialog');
  await page.setContent(`<div onclick='window.alert(123)'>Click me</div>`);
  await page.click('div', { timeout: 2000 }).catch(() => {});
  const dialog = await dialogPromise;
  await dialog.dismiss();
  await context.tracing.stop();
});

test('should ignore iframes in head', async ({ context, page, server }, testInfo) => {
  await page.goto(server.PREFIX + '/input/button.html');
  await page.evaluate(() => {
    document.head.appendChild(document.createElement('iframe'));
    // Add iframe in a shadow tree.
    const div = document.createElement('div');
    document.head.appendChild(div);
    const shadow = div.attachShadow({ mode: 'open' });
    shadow.appendChild(document.createElement('iframe'));
  });

  await context.tracing.start({ screenshots: true, snapshots: true });
  await page.click('button');
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });

  const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
  expect(trace.actions).toEqual(['Click']);
  expect(trace.events.find(e => e.type === 'frame-snapshot')).toBeTruthy();
  expect(trace.events.find(e => e.type === 'frame-snapshot' && JSON.stringify(e.snapshot.html).includes('IFRAME'))).toBeFalsy();
});

test('should hide internal stack frames', async ({ context, page }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  let evalPromise;
  page.on('dialog', dialog => {
    evalPromise = page.evaluate('2+2');
    void dialog.dismiss();
  });
  await page.setContent(`<div onclick='window.alert(123)'>Click me</div>`);
  await page.click('div');
  await evalPromise;
  const tracePath = testInfo.outputPath('trace.zip');
  await context.tracing.stop({ path: tracePath });

  const trace = await parseTraceRaw(tracePath);
  const actions = trace.actionObjects.filter(a => a.class !== 'Tracing');
  expect(actions).toHaveLength(4);
  for (const action of actions)
    expect(relativeStack(action, trace.stacks)).toEqual(['tracing.spec.ts']);
});

test('should hide internal stack frames in expect', async ({ context, page }, testInfo) => {
  await context.tracing.start({ screenshots: true, snapshots: true });
  let expectPromise;
  page.on('dialog', dialog => {
    expectPromise = expect(page).toHaveTitle('Hello');
    void dialog.dismiss();
  });
  await page.setContent(`<title>Hello</title><div onclick='window.alert(123)'>Click me</div>`);
  await page.click('div');
  await expect(page.locator('div')).toBeVisible();
  await expectPromise;
  const tracePath = testInfo.outputPath('trace.zip');
  await context.tracing.stop({ path: tracePath });

  const trace = await parseTraceRaw(tracePath);
  const actions = trace.actionObjects.filter(a => a.class !== 'Tracing');
  expect(actions).toHaveLength(5);
  for (const action of actions)
    expect(relativeStack(action, trace.stacks)).toEqual(['tracing.spec.ts']);
});

test('should record global request trace', async ({ request, context, server }, testInfo) => {
  await (request as any)._tracing.start({ snapshots: true });
  const url = server.PREFIX + '/simple.json';
  await request.get(url);
  const tracePath = testInfo.outputPath('trace.zip');
  await (request as any)._tracing.stop({ path: tracePath });

  const trace = await parseTraceRaw(tracePath);
  const actions = trace.events.filter(e => e.type === 'resource-snapshot');
  expect(actions).toHaveLength(1);
  expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
    method: 'GET',
    url
  }));
  expect(actions[0].snapshot.response).toEqual(expect.objectContaining({
    status: 200,
    statusText: 'OK',
    headers: expect.arrayContaining([
      expect.objectContaining({
        'name': 'Content-Length',
        'value': '15'
      })
    ])
  }));
});

test('should store global request traces separately', async ({ request, server, playwright, browserName, mode }, testInfo) => {
  const request2 = await playwright.request.newContext();
  await Promise.all([
    (request as any)._tracing.start({ snapshots: true }),
    (request2 as any)._tracing.start({ snapshots: true })
  ]);
  const url = server.PREFIX + '/simple.json';
  await Promise.all([
    request.get(url),
    request2.post(url)
  ]);
  const tracePath = testInfo.outputPath('trace.zip');
  const trace2Path = testInfo.outputPath('trace2.zip');
  await Promise.all([
    (request as any)._tracing.stop({ path: tracePath }),
    (request2 as any)._tracing.stop({ path: trace2Path })
  ]);
  {
    const trace = await parseTraceRaw(tracePath);
    const actions = trace.events.filter(e => e.type === 'resource-snapshot');
    expect(actions).toHaveLength(1);
    expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
      method: 'GET',
      url
    }));
  }
  {
    const trace = await parseTraceRaw(trace2Path);
    const actions = trace.events.filter(e => e.type === 'resource-snapshot');
    expect(actions).toHaveLength(1);
    expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
      method: 'POST',
      url
    }));
  }
});

test('should store postData for global request', async ({ request, server }, testInfo) => {
  testInfo.annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/15031' });
  await (request as any)._tracing.start({ snapshots: true });
  const url = server.PREFIX + '/simple.json';
  await request.post(url, {
    data: 'test'
  });
  const tracePath = testInfo.outputPath('trace.zip');
  await (request as any)._tracing.stop({ path: tracePath });

  const trace = await parseTraceRaw(tracePath);
  const actions = trace.events.filter(e => e.type === 'resource-snapshot');
  expect(actions).toHaveLength(1);
  const req = actions[0].snapshot.request;
  expect(req.postData?._sha1).toBeTruthy();
  expect(req).toEqual(expect.objectContaining({
    method: 'POST',
    url
  }));
});

test('should not flush console events', async ({ context, page, mode }, testInfo) => {
  test.skip(mode.startsWith('service'), 'Uses artifactsFolderName');
  const testId = test.info().testId;
  await context.tracing.start({ name: testId });
  const promise = new Promise<void>(f => {
    let counter = 0;
    page.on('console', () => {
      if (++counter === 100)
        f();
    });
  });

  await page.evaluate(() => {
    window.builtins.setTimeout(() => {
      for (let i = 0; i < 100; ++i)
        console.log('hello ' + i);
    }, 10);
    return 31415926;
  });

  await promise;

  const dir = path.join(testInfo.project.outputDir, artifactsFolderName(testInfo.workerIndex), 'traces');

  let content: string;
  await expect(async () => {
    const traceName = fs.readdirSync(dir).find(name => name.endsWith(testId + '.trace'));
    content = await fs.promises.readFile(path.join(dir, traceName), 'utf8');
    expect(content).toContain('31415926');
  }).toPass();
  expect(content).not.toContain('hello 0');

  await page.evaluate(() => 42);

  await expect(async () => {
    const traceName = fs.readdirSync(dir).find(name => name.endsWith(testId + '.trace'));
    const content = await fs.promises.readFile(path.join(dir, traceName), 'utf8');
    expect(content).toContain('hello 0');
    expect(content).toContain('hello 99');
  }).toPass();
});

test('should flush console events on tracing stop', async ({ context, page }, testInfo) => {
  await context.tracing.start();
  const promise = new Promise<void>(f => {
    let counter = 0;
    page.on('console', () => {
      if (++counter === 100)
        f();
    });
  });
  await page.evaluate(() => {
    window.builtins.setTimeout(() => {
      for (let i = 0; i < 100; ++i)
        console.log('hello ' + i);
    });
  });
  await promise;
  const tracePath = testInfo.outputPath('trace.zip');
  await context.tracing.stop({ path: tracePath });
  const trace = await parseTraceRaw(tracePath);
  const events = trace.events.filter(e => e.type === 'console');
  expect(events).toHaveLength(100);
});

test('should not emit after w/o before', async ({ browserType, mode }, testInfo) => {
  test.skip(mode.startsWith('service'), 'Service ignores tracesDir');

  const tracesDir = testInfo.outputPath('traces');
  const browser = await browserType.launch({ tracesDir });
  const context = await browser.newContext();
  const page = await context.newPage();

  await context.tracing.start({ name: 'name1', snapshots: true });
  const evaluatePromise = page.evaluate(() => {
    console.log('started');
    return new Promise(f => (window as any).callback = f);
  }).catch(() => {});
  await page.waitForEvent('console');
  await context.tracing.stopChunk({ path: testInfo.outputPath('trace1.zip') });
  expect(fs.existsSync(path.join(tracesDir, 'name1.trace'))).toBe(true);

  await context.tracing.startChunk({ name: 'name2' });
  await page.evaluateHandle(() => (window as any).callback());
  await evaluatePromise;
  await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') });
  expect(fs.existsSync(path.join(tracesDir, 'name2.trace'))).toBe(true);

  await browser.close();
  let minCallId = 100000;
  const sanitize = (e: any) => {
    if (e.type === 'after' || e.type === 'before') {
      minCallId = Math.min(minCallId, +e.callId.split('@')[1]);
      return {
        type: e.type,
        callId: +e.callId.split('@')[1] - minCallId,
        title: e.title,
      };
    }
  };

  let call1: number;
  {
    const { events } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
    const sanitized = events.map(sanitize).filter(Boolean);
    expect(sanitized).toEqual([
      {
        type: 'before',
        callId: expect.any(Number),
      },
      {
        type: 'before',
        callId: expect.any(Number),
      },
      {
        type: 'after',
        callId: expect.any(Number),
      },
    ]);
    call1 = sanitized[0].callId;
    expect(sanitized[1].callId).toBe(sanitized[2].callId);
  }

  let call2before: number;
  let call2after: number;
  {
    const { events } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
    const sanitized = events.map(sanitize).filter(Boolean);
    expect(sanitized).toEqual([
      {
        type: 'before',
        callId: expect.any(Number),
      },
      {
        type: 'after',
        callId: expect.any(Number),
      }
    ]);
    call2before = sanitized[0].callId;
    call2after = sanitized[1].callId;
  }
  expect(call2before).toBeGreaterThan(call1);
  expect(call2after).toBe(call2before);
});

function expectRed(pixels: Buffer, offset: number) {
  const r = pixels.readUInt8(offset);
  const g = pixels.readUInt8(offset + 1);
  const b = pixels.readUInt8(offset + 2);
  const a = pixels.readUInt8(offset + 3);
  expect(r).toBeGreaterThan(200);
  expect(g).toBeLessThan(70);
  expect(b).toBeLessThan(70);
  expect(a).toBe(255);
}

function expectBlue(pixels: Buffer, offset: number) {
  const r = pixels.readUInt8(offset);
  const g = pixels.readUInt8(offset + 1);
  const b = pixels.readUInt8(offset + 2);
  const a = pixels.readUInt8(offset + 3);
  expect(r).toBeLessThan(70);
  expect(g).toBeLessThan(70);
  expect(b).toBeGreaterThan(200);
  expect(a).toBe(255);
}

function relativeStack(action: ActionTraceEvent, stacks: Map<string, StackFrame[]>): string[] {
  const stack = stacks.get(action.callId) || [];
  return stack.map(f => f.file.replace(__dirname + path.sep, ''));
}
