/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
import {describe, it} from 'node:test';

import sinon from 'sinon';

import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpResponse} from '../../src/McpResponse.js';
import {TextSnapshot} from '../../src/TextSnapshot.js';
import {
  click,
  hover,
  fill,
  drag,
  fillForm,
  uploadFile,
  pressKey,
  clickAt,
  typeText,
} from '../../src/tools/input.js';
import {parseKey} from '../../src/utils/keyboard.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext, getTextContent} from '../utils.js';

describe('input', () => {
  const server = serverHooks();

  describe('click', () => {
    it('clicks', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button onclick="this.innerText = 'clicked';">test</button>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/clicked'));
      });
    });
    it('double clicks', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button ondblclick="this.innerText = 'dblclicked';"
            >test</button
          >`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_1',
              dblClick: true,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully double clicked on the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/dblclicked'));
      });
    });
    it('waits for navigation', async () => {
      const resolveNavigation = Promise.withResolvers<void>();
      server.addHtmlRoute(
        '/link',
        html`<a href="/navigated">Navigate page</a>`,
      );
      server.addRoute('/navigated', async (_req, res) => {
        await resolveNavigation.promise;
        res.write(html`<main>I was navigated</main>`);
        res.end();
      });

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.goto(server.getRoute('/link'));
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        const clickPromise = click.handler(
          {
            params: {
              uid: '1_1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        const [t1, t2] = await Promise.all([
          clickPromise.then(() => Date.now()),
          new Promise<number>(res => {
            setTimeout(() => {
              resolveNavigation.resolve();
              res(Date.now());
            }, 300);
          }),
        ]);

        assert(t1 > t2, 'Waited for navigation');
      });
    });

    it('reports the new URL when click triggers a navigation', async () => {
      server.addHtmlRoute(
        '/start',
        html`<a href="/after-click">Navigate page</a>`,
      );
      server.addHtmlRoute('/after-click', html`<main>arrived</main>`);

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.goto(server.getRoute('/start'));
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_2',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        const result = await response.handle('click', context);
        const textContent = getTextContent(result.content[0]);
        const expectedUrl = server.getRoute('/after-click');
        assert.ok(
          textContent.includes(`Page navigated to ${expectedUrl}.`),
          `Expected response to mention navigation to ${expectedUrl}, got: ${textContent}`,
        );
      });
    });

    it('does not report navigation when click does not navigate', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button onclick="this.innerText = 'clicked';">test</button>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        const result = await response.handle('click', context);
        const textContent = getTextContent(result.content[0]);
        assert.ok(
          !textContent.includes('Page navigated to '),
          `Did not expect a navigation line, got: ${textContent}`,
        );
      });
    });

    it('waits for stable DOM', async () => {
      server.addHtmlRoute(
        '/unstable',
        html`
          <button>Click to change to see time</button>
          <script>
            const button = document.querySelector('button');
            button.addEventListener('click', () => {
              setTimeout(() => {
                button.textContent = Date.now();
              }, 50);
            });
          </script>
        `,
      );
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.goto(server.getRoute('/unstable'));
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        const handlerResolveTime = await click
          .handler(
            {
              params: {
                uid: '1_1',
              },
              page: context.getSelectedMcpPage(),
            },
            response,
            context,
          )
          .then(() => Date.now());
        const buttonChangeTime = await page.evaluate(() => {
          const button = document.querySelector('button');
          return Number(button?.textContent);
        });

        assert(handlerResolveTime > buttonChangeTime, 'Waited for navigation');
      });
    });

    it('does not include snapshot by default', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button onclick="this.innerText = 'clicked';">test</button>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.strictEqual(response.snapshotParams, undefined);
      });
    });

    it('includes snapshot if includeSnapshot is true', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button onclick="this.innerText = 'clicked';">test</button>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await click.handler(
          {
            params: {
              uid: '1_1',
              includeSnapshot: true,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.notStrictEqual(response.snapshotParams, undefined);
      });
    });

    it('selects a collapsed native select option by option uid', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<select onchange="document.body.dataset.selected = this.value">
            <option value="v1">one</option>
            <option value="v2">two</option>
          </select>`,
        );
        const mcpPage = context.getSelectedMcpPage();
        mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
        const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
          node => node.role === 'option' && node.name === 'two',
        );
        assert.ok(optionNode);

        await click.handler(
          {
            params: {
              uid: optionNode.id,
            },
            page: mcpPage,
          },
          response,
          context,
        );

        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.deepStrictEqual(
          await page.evaluate(() => {
            const select = document.querySelector('select');
            return {
              selectedValue: select?.value,
              changeEventValue: document.body.dataset.selected,
            };
          }),
          {
            selectedValue: 'v2',
            changeEventValue: 'v2',
          },
        );
      });
    });

    it('selects a collapsed native optgroup option by option uid', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<select onchange="document.body.dataset.selected = this.value">
            <optgroup label="Numbers">
              <option value="v1">one</option>
              <option value="v2">two</option>
            </optgroup>
          </select>`,
        );
        const mcpPage = context.getSelectedMcpPage();
        mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
        const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
          node => node.role === 'option' && node.name === 'two',
        );
        assert.ok(optionNode);

        await click.handler(
          {
            params: {
              uid: optionNode.id,
            },
            page: mcpPage,
          },
          response,
          context,
        );

        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.deepStrictEqual(
          await page.evaluate(() => {
            const select = document.querySelector('select');
            return {
              selectedValue: select?.value,
              changeEventValue: document.body.dataset.selected,
            };
          }),
          {
            selectedValue: 'v2',
            changeEventValue: 'v2',
          },
        );
      });
    });

    it('clicks custom ARIA option elements through the normal click path', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<div role="listbox">
            <div
              role="option"
              tabindex="0"
              onclick="document.body.dataset.clicked = this.textContent.trim()"
            >
              custom two
            </div>
          </div>`,
        );
        const mcpPage = context.getSelectedMcpPage();
        mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
        const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
          node => node.role === 'option' && node.name === 'custom two',
        );
        assert.ok(optionNode);

        await click.handler(
          {
            params: {
              uid: optionNode.id,
            },
            page: mcpPage,
          },
          response,
          context,
        );

        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked on the element',
        );
        assert.strictEqual(
          await page.evaluate(() => document.body.dataset.clicked),
          'custom two',
        );
      });
    });
  });

  describe('hover', () => {
    it('hovers', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button onmouseover="this.innerText = 'hovered';">test</button>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await hover.handler(
          {
            params: {
              uid: '1_1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully hovered over the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/hovered'));
      });
    });
  });

  describe('click_at', () => {
    it('clicks at coordinates', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<div
            style="width: 100px; height: 100px; background: red;"
            onclick="this.innerText = 'clicked'"
          ></div>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await clickAt.handler(
          {
            params: {
              x: 50,
              y: 50,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully clicked at the coordinates',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/clicked'));
      });
    });

    it('double clicks at coordinates', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<div
            style="width: 100px; height: 100px; background: red;"
            ondblclick="this.innerText = 'dblclicked'"
          ></div>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await clickAt.handler(
          {
            params: {
              x: 50,
              y: 50,
              dblClick: true,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully double clicked at the coordinates',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/dblclicked'));
      });
    });
  });

  describe('fill', () => {
    it('fills out an input', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<input />`);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'test',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(await page.$('text/test'));
      });
    });

    it('fills out a select by text', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<select
            ><option value="v1">one</option
            ><option value="v2">two</option></select
          >`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'two',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        const selectedValue = await page.evaluate(
          () => document.querySelector('select')!.value,
        );
        assert.strictEqual(selectedValue, 'v2');
      });
    });

    it('fills out a textarea marked as combobox', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<textarea role="combobox"></textarea>`);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: '1',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(
          await page.evaluate(() => {
            return document.body.querySelector('textarea')?.value === '1';
          }),
        );
      });
    });

    it('fills out a textarea with long text', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<textarea></textarea>`);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        page.setDefaultTimeout(1000);
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: '1'.repeat(3000),
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        assert.ok(
          await page.evaluate(() => {
            return (
              document.body.querySelector('textarea')?.value.length === 3_000
            );
          }),
        );
      });
    });

    it('types text', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<textarea></textarea>`);
        await page.click('textarea');
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await typeText.handler(
          {
            params: {
              text: 'test',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(response.responseLines[0], 'Typed text "test"');
        assert.strictEqual(
          await page.evaluate(() => {
            return document.body.querySelector('textarea')?.value;
          }),
          'test',
        );
      });
    });

    it('types text with submit key', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<textarea></textarea>`);
        await page.click('textarea');
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await typeText.handler(
          {
            params: {
              text: 'test',
              submitKey: 'Tab',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          response.responseLines[0],
          'Typed text "test + Tab"',
        );
        assert.strictEqual(
          await page.evaluate(() => {
            return document.body.querySelector('textarea')?.value;
          }),
          'test',
        );
        assert.ok(
          await page.evaluate(() => {
            return (
              document.body.querySelector('textarea') !== document.activeElement
            );
          }),
        );
      });
    });

    it('errors on invalid submit key', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<textarea></textarea>`);
        await page.click('textarea');
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        try {
          await typeText.handler(
            {
              params: {
                text: 'test',
                submitKey: 'XXX',
              },
              page: context.getSelectedMcpPage(),
            },
            response,
            context,
          );
        } catch (err) {
          assert.strictEqual(err.message, 'Unknown key: "XXX"');
        }
      });
    });

    it('reproduction: fill isolation', async () => {
      await withMcpContext(async (_response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<form>
            <input
              id="email"
              value="user@test.com"
            />
            <input
              id="password"
              type="password"
            />
          </form>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        // Fill email
        const response1 = new McpResponse({} as ParsedArguments);
        await fill.handler(
          {
            params: {
              uid: '1_2', // email input
              value: 'new@test.com',
            },
            page: context.getSelectedMcpPage(),
          },
          response1,
          context,
        );
        assert.strictEqual(
          response1.responseLines[0],
          'Successfully filled out the element',
        );

        // Fill password
        const response2 = new McpResponse({} as ParsedArguments);
        await fill.handler(
          {
            params: {
              uid: '1_3', // password input
              value: 'secret',
            },
            page: context.getSelectedMcpPage(),
          },
          response2,
          context,
        );
        assert.strictEqual(
          response2.responseLines[0],
          'Successfully filled out the element',
        );

        // Verify values
        const values = await page.evaluate(() => {
          return {
            email: (document.getElementById('email') as HTMLInputElement).value,
            password: (document.getElementById('password') as HTMLInputElement)
              .value,
          };
        });

        assert.strictEqual(
          values.email,
          'new@test.com',
          'Email should be updated correctly',
        );
        assert.strictEqual(
          values.password,
          'secret',
          'Password should be updated correctly',
        );
      });
    });

    it('toggles checkboxes', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<input
            type="checkbox"
            id="cb"
          />`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        // Check it
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'true',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );

        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the element',
        );
        assert.ok(response.includeSnapshot);
        let isChecked = await page.$eval(
          '#cb',
          el => (el as HTMLInputElement).checked,
        );
        assert.strictEqual(isChecked, true);

        // Uncheck it
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'false',
            },
            page: context.getSelectedMcpPage(),
          },
          new McpResponse({} as ParsedArguments),
          context,
        );

        isChecked = await page.$eval(
          '#cb',
          el => (el as HTMLInputElement).checked,
        );
        assert.strictEqual(isChecked, false);
      });
    });

    it('toggles switches', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`
          <div
            role="switch"
            aria-checked="false"
            id="sw"
            style="width: 20px; height: 20px; background: blue;"
            onclick="this.setAttribute('aria-checked', this.getAttribute('aria-checked') === 'true' ? 'false' : 'true')"
          >
            switch
          </div>
        `);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        // Turn it on
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'true',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );

        let swChecked = await page.$eval(
          '#sw',
          el => el.getAttribute('aria-checked') === 'true',
        );
        assert.strictEqual(swChecked, true);

        // Turn it off
        await fill.handler(
          {
            params: {
              uid: '1_1',
              value: 'false',
            },
            page: context.getSelectedMcpPage(),
          },
          new McpResponse({} as ParsedArguments),
          context,
        );

        swChecked = await page.$eval(
          '#sw',
          el => el.getAttribute('aria-checked') === 'true',
        );
        assert.strictEqual(swChecked, false);
      });
    });

    it('selects radio buttons', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`
          <input
            type="radio"
            name="group1"
            id="r1"
            checked
          />
          <input
            type="radio"
            name="group1"
            id="r2"
          />
        `);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        // Initial state
        let r1Checked = await page.$eval(
          '#r1',
          el => (el as HTMLInputElement).checked,
        );
        let r2Checked = await page.$eval(
          '#r2',
          el => (el as HTMLInputElement).checked,
        );
        assert.strictEqual(r1Checked, true);
        assert.strictEqual(r2Checked, false);

        // Fill second radio with true
        await fill.handler(
          {
            params: {
              uid: '1_2',
              value: 'true',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );

        r1Checked = await page.$eval(
          '#r1',
          el => (el as HTMLInputElement).checked,
        );
        r2Checked = await page.$eval(
          '#r2',
          el => (el as HTMLInputElement).checked,
        );
        assert.strictEqual(r1Checked, false);
        assert.strictEqual(r2Checked, true);
      });
    });
  });

  describe('drags', () => {
    it('drags one element onto another', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<div
              role="button"
              id="drag"
              draggable="true"
              >drag me</div
            >
            <div
              id="drop"
              aria-label="drop"
              style="width: 100px; height: 100px; border: 1px solid black;"
              ondrop="this.innerText = 'dropped';"
            >
            </div>
            <script>
              drag.addEventListener('dragstart', event => {
                event.dataTransfer.setData('text/plain', event.target.id);
              });
              drop.addEventListener('dragover', event => {
                event.preventDefault();
                event.dataTransfer.dropEffect = 'move';
              });
              drop.addEventListener('drop', event => {
                event.preventDefault();
                const data = event.dataTransfer.getData('text/plain');
                event.target.appendChild(document.getElementById(data));
              });
            </script>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await drag.handler(
          {
            params: {
              from_uid: '1_1',
              to_uid: '1_2',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          'Successfully dragged an element',
        );
        assert.ok(await page.$('text/dropped'));
      });
    });
  });

  describe('fill form', () => {
    it('successfully fills out the form', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<form>
            <label
              >username<input
                name="username"
                type="text"
            /></label>
            <label
              >email<input
                name="email"
                type="text"
            /></label>
            <input
              type="submit"
              value="Submit"
            />
          </form>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await fillForm.handler(
          {
            params: {
              elements: [
                {
                  uid: '1_3',
                  value: 'test',
                },
                {
                  uid: '1_5',
                  value: 'test2',
                },
              ],
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          'Successfully filled out the form',
        );
        assert.deepStrictEqual(
          await page.evaluate(() => {
            return [
              // @ts-expect-error missing types
              document.querySelector('input[name=username]').value,
              // @ts-expect-error missing types
              document.querySelector('input[name=email]').value,
            ];
          }),
          ['test', 'test2'],
        );
      });
    });

    it('fill_form handles checkboxes', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<input
              name="username"
              type="text"
            /><input
              name="cb"
              type="checkbox"
            />`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await fillForm.handler(
          {
            params: {
              elements: [
                {
                  uid: '1_1',
                  value: 'test',
                },
                {
                  uid: '1_2',
                  value: 'true',
                },
              ],
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.strictEqual(
          await page.evaluate(() => {
            // @ts-expect-error missing types
            return document.querySelector('input[name=username]').value;
          }),
          'test',
        );
        assert.strictEqual(
          await page.evaluate(() => {
            // @ts-expect-error missing types
            return document.querySelector('input[name=cb]').checked;
          }),
          true,
        );
      });
    });
  });

  describe('uploadFile', () => {
    it('uploads a file to a file input', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<form>
            <input
              type="file"
              id="file-input"
            />
          </form>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await uploadFile.handler(
          {
            params: {
              uid: '1_2',
              filePath: testFilePath,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          `File uploaded from ${testFilePath}.`,
        );
      });

      await fs.unlink(testFilePath);
    });

    it('uploads a file when clicking an element opens a file uploader', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<button id="file-chooser-button">Upload file</button>
            <input
              type="file"
              id="file-input"
              style="display: none;"
            />
            <script>
              document
                .getElementById('file-chooser-button')
                .addEventListener('click', () => {
                  document.getElementById('file-input').click();
                });
            </script>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );
        await uploadFile.handler(
          {
            params: {
              uid: '1_1',
              filePath: testFilePath,
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );
        assert.ok(response.includeSnapshot);
        assert.strictEqual(
          response.responseLines[0],
          `File uploaded from ${testFilePath}.`,
        );
        const uploadedFileName = await page.$eval('#file-input', el => {
          const input = el as HTMLInputElement;
          return input.files?.[0]?.name;
        });
        assert.strictEqual(uploadedFileName, 'test.txt');

        await fs.unlink(testFilePath);
      });
    });

    it('throws an error if the element is not a file input and does not open a file chooser', async () => {
      const testFilePath = path.join(process.cwd(), 'test.txt');
      await fs.writeFile(testFilePath, 'test file content');

      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(html`<div>Not a file input</div>`);
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        await assert.rejects(
          uploadFile.handler(
            {
              params: {
                uid: '1_1',
                filePath: testFilePath,
              },
              page: context.getSelectedMcpPage(),
            },
            response,
            context,
          ),
          {
            message:
              'Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.',
          },
        );

        assert.strictEqual(response.responseLines.length, 0);
        assert.strictEqual(response.snapshotParams, undefined);

        await fs.unlink(testFilePath);
      });
    });
  });

  describe('press_key', () => {
    it('parses keys', () => {
      assert.deepStrictEqual(parseKey('Shift+A'), ['A', 'Shift']);
      assert.deepStrictEqual(parseKey('Shift++'), ['+', 'Shift']);
      assert.deepStrictEqual(parseKey('Control+Shift++'), [
        '+',
        'Control',
        'Shift',
      ]);
      assert.deepStrictEqual(parseKey('Shift'), ['Shift']);
      assert.deepStrictEqual(parseKey('KeyA'), ['KeyA']);
    });
    it('throws on empty key', () => {
      assert.throws(() => {
        parseKey('');
      });
    });
    it('throws on invalid key', () => {
      assert.throws(() => {
        parseKey('aaaaa');
      });
    });
    it('throws on multiple keys', () => {
      assert.throws(() => {
        parseKey('Shift+Shift');
      });
    });

    it('processes press_key', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<script>
            logs = [];
            document.addEventListener('keydown', e => logs.push('d' + e.key));
            document.addEventListener('keyup', e => logs.push('u' + e.key));
          </script>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        await pressKey.handler(
          {
            params: {
              key: 'Control+Shift+C',
            },
            page: context.getSelectedMcpPage(),
          },
          response,
          context,
        );

        assert.deepStrictEqual(await page.evaluate('logs'), [
          'dControl',
          'dShift',
          'dC',
          'uC',
          'uShift',
          'uControl',
        ]);
      });
    });

    it('releases held modifiers when the main key press fails', async () => {
      await withMcpContext(async (response, context) => {
        const page = context.getSelectedMcpPage().pptrPage;
        await page.setContent(
          html`<script>
            logs = [];
            document.addEventListener('keydown', e => logs.push('d' + e.key));
            document.addEventListener('keyup', e => logs.push('u' + e.key));
          </script>`,
        );
        context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
          context.getSelectedMcpPage(),
        );

        // Simulate the main key press failing mid-sequence (e.g. a CDP
        // hiccup) after the modifiers have already been pressed down.
        sinon
          .stub(page.keyboard, 'press')
          .throws(new Error('injected press failure'));

        try {
          await assert.rejects(
            pressKey.handler(
              {
                params: {
                  key: 'Control+Shift+C',
                },
                page: context.getSelectedMcpPage(),
              },
              response,
              context,
            ),
          );
        } finally {
          sinon.restore();
        }

        // The modifiers were pressed down; both must be released even though
        // the main key press threw, otherwise the browser is left with the
        // modifiers logically stuck down.
        assert.deepStrictEqual(await page.evaluate('logs'), [
          'dControl',
          'dShift',
          'uShift',
          'uControl',
        ]);
      });
    });
  });
});
