// META: timeout=long
// META: global=window,worker
// META: script=/common/utils.js
// META: script=/common/get-host-info.sub.js
// META: script=../request/request-error.js

const BODY_METHODS = ['arrayBuffer', 'blob', 'bytes', 'formData', 'json', 'text'];

const error1 = new Error('error1');
error1.name = 'error1';

// This is used to close connections that weren't correctly closed during the tests,
// otherwise you can end up running out of HTTP connections.
let requestAbortKeys = [];

function abortRequests() {
  const keys = requestAbortKeys;
  requestAbortKeys = [];
  return Promise.all(
    keys.map(key => fetch(`../resources/stash-put.py?key=${key}&value=close`))
  );
}

const hostInfo = get_host_info();
const urlHostname = hostInfo.REMOTE_HOST;

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const fetchPromise = fetch('../resources/data.json', { signal });

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Aborting rejects with AbortError");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort(error1);

  const fetchPromise = fetch('../resources/data.json', { signal });

  await promise_rejects_exactly(t, error1, fetchPromise, 'fetch() should reject with abort reason');
}, "Aborting rejects with abort reason");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const url = new URL('../resources/data.json', location);
  url.hostname = urlHostname;

  const fetchPromise = fetch(url, {
    signal,
    mode: 'no-cors'
  });

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Aborting rejects with AbortError - no-cors");

// Test that errors thrown from the request constructor take priority over abort errors.
// badRequestArgTests is from response-error.js
for (const { args, testName } of badRequestArgTests) {
  promise_test(async t => {
    try {
      // If this doesn't throw, we'll effectively skip the test.
      // It'll fail properly in ../request/request-error.html
      new Request(...args);
    }
    catch (err) {
      const controller = new AbortController();
      controller.abort();

      // Add signal to 2nd arg
      args[1] = args[1] || {};
      args[1].signal = controller.signal;
      await promise_rejects_js(t, TypeError, fetch(...args));
    }
  }, `TypeError from request constructor takes priority - ${testName}`);
}

test(() => {
  const request = new Request('');
  assert_true(Boolean(request.signal), "Signal member is present & truthy");
  assert_equals(request.signal.constructor, AbortSignal);
}, "Request objects have a signal property");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', { signal });

  assert_true(Boolean(request.signal), "Signal member is present & truthy");
  assert_equals(request.signal.constructor, AbortSignal);
  assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference');
  assert_true(request.signal.aborted, `Request's signal has aborted`);

  const fetchPromise = fetch(request);

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal on request object");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort(error1);

  const request = new Request('../resources/data.json', { signal });

  assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference');
  assert_true(request.signal.aborted, `Request's signal has aborted`);
  assert_equals(request.signal.reason, error1, `Request's signal's abort reason is error1`);

  const fetchPromise = fetch(request);

  await promise_rejects_exactly(t, error1, fetchPromise, "fetch() should reject with abort reason");
}, "Signal on request object should also have abort reason");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', { signal });
  const requestFromRequest = new Request(request);

  const fetchPromise = fetch(requestFromRequest);

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal on request object created from request object");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json');
  const requestFromRequest = new Request(request, { signal });

  const fetchPromise = fetch(requestFromRequest);

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal on request object created from request object, with signal on second request");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', { signal: new AbortController().signal });
  const requestFromRequest = new Request(request, { signal });

  const fetchPromise = fetch(requestFromRequest);

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal on request object created from request object, with signal on second request overriding another");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', { signal });

  const fetchPromise = fetch(request, {method: 'POST'});

  await promise_rejects_dom(t, "AbortError", fetchPromise);
}, "Signal retained after unrelated properties are overridden by fetch");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', { signal });

  const data = await fetch(request, { signal: null }).then(r => r.json());
  assert_equals(data.key, 'value', 'Fetch fully completes');
}, "Signal removed by setting to null");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const log = [];

  await Promise.all([
    fetch('../resources/data.json', { signal }).then(
      () => assert_unreached("Fetch must not resolve"),
      () => log.push('fetch-reject')
    ),
    Promise.resolve().then(() => log.push('next-microtask'))
  ]);

  assert_array_equals(log, ['fetch-reject', 'next-microtask']);
}, "Already aborted signal rejects immediately");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('../resources/data.json', {
    signal,
    method: 'POST',
    body: 'foo',
    headers: { 'Content-Type': 'text/plain' }
  });

  await fetch(request).catch(() => {});

  assert_true(request.bodyUsed, "Body has been used");
}, "Request is still 'used' if signal is aborted before fetching");

for (const bodyMethod of BODY_METHODS) {
  promise_test(async t => {
    const controller = new AbortController();
    const signal = controller.signal;

    const log = [];
    const response = await fetch('../resources/data.json', { signal });

    controller.abort();

    const bodyPromise = response[bodyMethod]();

    await Promise.all([
      bodyPromise.catch(() => log.push(`${bodyMethod}-reject`)),
      Promise.resolve().then(() => log.push('next-microtask'))
    ]);

    await promise_rejects_dom(t, "AbortError", bodyPromise);

    assert_array_equals(log, [`${bodyMethod}-reject`, 'next-microtask']);
  }, `response.${bodyMethod}() rejects if already aborted`);
}

promise_test(async (t) => {
  const controller = new AbortController();
  const signal = controller.signal;

  const res = await fetch('../resources/data.json', { signal });
  controller.abort();

  await promise_rejects_dom(t, 'AbortError', res.text());
  await promise_rejects_dom(t, 'AbortError', res.text());
}, 'Call text() twice on aborted response');

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  const stateKey = token();
  const abortKey = token();
  requestAbortKeys.push(abortKey);
  controller.abort();

  await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }).catch(() => {});

  // I'm hoping this will give the browser enough time to (incorrectly) make the request
  // above, if it intends to.
  await fetch('../resources/data.json').then(r => r.json());

  const response = await fetch(`../resources/stash-take.py?key=${stateKey}`);
  const data = await response.json();

  assert_equals(data, null, "Request hasn't been made to the server");
}, "Already aborted signal does not make request");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const fetches = [];

  for (let i = 0; i < 3; i++) {
    const abortKey = token();
    requestAbortKeys.push(abortKey);

    fetches.push(
      fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal })
    );
  }

  for (const fetchPromise of fetches) {
    await promise_rejects_dom(t, "AbortError", fetchPromise);
  }
}, "Already aborted signal can be used for many fetches");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;

  await fetch('../resources/data.json', { signal }).then(r => r.json());

  controller.abort();

  const fetches = [];

  for (let i = 0; i < 3; i++) {
    const abortKey = token();
    requestAbortKeys.push(abortKey);

    fetches.push(
      fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal })
    );
  }

  for (const fetchPromise of fetches) {
    await promise_rejects_dom(t, "AbortError", fetchPromise);
  }
}, "Signal can be used to abort other fetches, even if another fetch succeeded before aborting");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  const stateKey = token();
  const abortKey = token();
  requestAbortKeys.push(abortKey);

  await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });

  const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
  assert_equals(beforeAbortResult, "open", "Connection is open");

  controller.abort();

  // The connection won't close immediately, but it should close at some point:
  const start = Date.now();

  while (true) {
    // Stop spinning if 10 seconds have passed
    if (Date.now() - start > 10000) throw Error('Timed out');

    const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
    if (afterAbortResult == 'closed') break;
  }
}, "Underlying connection is closed when aborting after receiving response");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  const stateKey = token();
  const abortKey = token();
  requestAbortKeys.push(abortKey);

  const url = new URL(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, location);
  url.hostname = urlHostname;

  await fetch(url, {
    signal,
    mode: 'no-cors'
  });

  const stashTakeURL = new URL(`../resources/stash-take.py?key=${stateKey}`, location);
  stashTakeURL.hostname = urlHostname;

  const beforeAbortResult = await fetch(stashTakeURL).then(r => r.json());
  assert_equals(beforeAbortResult, "open", "Connection is open");

  controller.abort();

  // The connection won't close immediately, but it should close at some point:
  const start = Date.now();

  while (true) {
    // Stop spinning if 10 seconds have passed
    if (Date.now() - start > 10000) throw Error('Timed out');

    const afterAbortResult = await fetch(stashTakeURL).then(r => r.json());
    if (afterAbortResult == 'closed') break;
  }
}, "Underlying connection is closed when aborting after receiving response - no-cors");

for (const bodyMethod of BODY_METHODS) {
  promise_test(async t => {
    await abortRequests();

    const controller = new AbortController();
    const signal = controller.signal;
    const stateKey = token();
    const abortKey = token();
    requestAbortKeys.push(abortKey);

    const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });

    const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
    assert_equals(beforeAbortResult, "open", "Connection is open");

    const bodyPromise = response[bodyMethod]();

    controller.abort();

    await promise_rejects_dom(t, "AbortError", bodyPromise);

    const start = Date.now();

    while (true) {
      // Stop spinning if 10 seconds have passed
      if (Date.now() - start > 10000) throw Error('Timed out');

      const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
      if (afterAbortResult == 'closed') break;
    }
  }, `Fetch aborted & connection closed when aborted after calling response.${bodyMethod}()`);
}

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  const stateKey = token();
  const abortKey = token();
  requestAbortKeys.push(abortKey);

  const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
  const reader = response.body.getReader();

  controller.abort();

  await promise_rejects_dom(t, "AbortError", reader.read());
  await promise_rejects_dom(t, "AbortError", reader.closed);

  // The connection won't close immediately, but it should close at some point:
  const start = Date.now();

  while (true) {
    // Stop spinning if 10 seconds have passed
    if (Date.now() - start > 10000) throw Error('Timed out');

    const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
    if (afterAbortResult == 'closed') break;
  }
}, "Stream errors once aborted. Underlying connection closed.");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;
  const stateKey = token();
  const abortKey = token();
  requestAbortKeys.push(abortKey);

  const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal });
  const reader = response.body.getReader();

  await reader.read();

  controller.abort();

  await promise_rejects_dom(t, "AbortError", reader.read());
  await promise_rejects_dom(t, "AbortError", reader.closed);

  // The connection won't close immediately, but it should close at some point:
  const start = Date.now();

  while (true) {
    // Stop spinning if 10 seconds have passed
    if (Date.now() - start > 10000) throw Error('Timed out');

    const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json());
    if (afterAbortResult == 'closed') break;
  }
}, "Stream errors once aborted, after reading. Underlying connection closed.");

promise_test(async t => {
  await abortRequests();

  const controller = new AbortController();
  const signal = controller.signal;

  const response = await fetch(`../resources/empty.txt`, { signal });

  // Read whole response to ensure close signal has sent.
  await response.clone().text();

  const reader = response.body.getReader();

  controller.abort();

  const item = await reader.read();

  assert_true(item.done, "Stream is done");
}, "Stream will not error if body is empty. It's closed with an empty queue before it errors.");

promise_test(async t => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  let cancelReason;

  const body = new ReadableStream({
    pull(controller) {
      controller.enqueue(new Uint8Array([42]));
    },
    cancel(reason) {
      cancelReason = reason;
    }
  });

  const fetchPromise = fetch('../resources/empty.txt', {
    body, signal,
    method: 'POST',
    duplex: 'half',
    headers: {
      'Content-Type': 'text/plain'
    }
  });

  assert_true(!!cancelReason, 'Cancel called sync');
  assert_equals(cancelReason.constructor, DOMException);
  assert_equals(cancelReason.name, 'AbortError');

  await promise_rejects_dom(t, "AbortError", fetchPromise);

  const fetchErr = await fetchPromise.catch(e => e);

  assert_equals(cancelReason, fetchErr, "Fetch rejects with same error instance");
}, "Readable stream synchronously cancels with AbortError if aborted before reading");

test(() => {
  const controller = new AbortController();
  const signal = controller.signal;
  controller.abort();

  const request = new Request('.', { signal });
  const requestSignal = request.signal;

  const clonedRequest = request.clone();

  assert_equals(requestSignal, request.signal, "Original request signal the same after cloning");
  assert_true(request.signal.aborted, "Original request signal aborted");
  assert_not_equals(clonedRequest.signal, request.signal, "Cloned request has different signal");
  assert_true(clonedRequest.signal.aborted, "Cloned request signal aborted");
}, "Signal state is cloned");

test(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  const request = new Request('.', { signal });
  const clonedRequest = request.clone();

  const log = [];

  request.signal.addEventListener('abort', () => log.push('original-aborted'));
  clonedRequest.signal.addEventListener('abort', () => log.push('clone-aborted'));

  controller.abort();

  assert_array_equals(log, ['original-aborted', 'clone-aborted'], "Abort events fired in correct order");
  assert_true(request.signal.aborted, 'Signal aborted');
  assert_true(clonedRequest.signal.aborted, 'Signal aborted');
}, "Clone aborts with original controller");
