"use strict";

// Different sizes of payloads to test.
var smallPayloadSize = 10;
var mediumPayloadSize = 10000;
var largePayloadSize = 50000;
var maxPayloadSize = 65536; // The maximum payload size allowed for a beacon request.

// String payloads of various sizes sent by sendbeacon. The format of the payloads is a string:
//     <numberOfCharacters>:<numberOfCharacters *'s>
//     ex. "10:**********"
var smallPayload = smallPayloadSize + ":" + Array(smallPayloadSize).fill('*').join("");
var mediumPayload = mediumPayloadSize + ":" + Array(mediumPayloadSize).fill('*').join("");
var largePayload = largePayloadSize + ":" + Array(largePayloadSize).fill('*').join("");
// Subtract 6 from maxPayloadSize because 65536 is 5 digits, plus 1 more for the ':'
var maxPayload = (maxPayloadSize - 6) + ":" + Array(maxPayloadSize - 6).fill('*').join("")

// Test case definitions.
//      id: String containing the unique name of the test case.
//      data: Payload object to send through sendbeacon.
var noDataTest = { id: "NoData" };
var nullDataTest = { id: "NullData", data: null };
var undefinedDataTest = { id: "UndefinedData", data: undefined };
var smallStringTest = { id: "SmallString", data: smallPayload };
var mediumStringTest = { id: "MediumString", data: mediumPayload };
var largeStringTest = { id: "LargeString", data: largePayload };
var maxStringTest = { id: "MaxString", data: maxPayload };
var emptyBlobTest = { id: "EmptyBlob", data: new Blob() };
var smallBlobTest = { id: "SmallBlob", data: new Blob([smallPayload]) };
var mediumBlobTest = { id: "MediumBlob", data: new Blob([mediumPayload]) };
var largeBlobTest = { id: "LargeBlob", data: new Blob([largePayload]) };
var maxBlobTest = { id: "MaxBlob", data: new Blob([maxPayload]) };
var emptyBufferSourceTest = { id: "EmptyBufferSource", data: new Uint8Array() };
var smallBufferSourceTest = { id: "SmallBufferSource", data: CreateArrayBufferFromPayload(smallPayload) };
var mediumBufferSourceTest = { id: "MediumBufferSource", data: CreateArrayBufferFromPayload(mediumPayload) };
var largeBufferSourceTest = { id: "LargeBufferSource", data: CreateArrayBufferFromPayload(largePayload) };
var maxBufferSourceTest = { id: "MaxBufferSource", data: CreateArrayBufferFromPayload(maxPayload) };
var emptyFormDataTest = { id: "EmptyFormData", data: CreateEmptyFormDataPayload() };
var smallFormDataTest = { id: "SmallFormData", data: CreateFormDataFromPayload(smallPayload) };
var mediumFormDataTest = { id: "MediumFormData", data: CreateFormDataFromPayload(mediumPayload) };
var largeFormDataTest = { id: "LargeFormData", data: CreateFormDataFromPayload(largePayload) };
// We don't test maxFormData because the extra multipart separators make it difficult to
// calculate a maxPayload.

// Test case suites.
// Due to quota limits we split the max payload tests into their own bucket.
var stringTests = [noDataTest, nullDataTest, undefinedDataTest, smallStringTest, mediumStringTest, largeStringTest];
var stringMaxTest = [maxStringTest];
var blobTests = [emptyBlobTest, smallBlobTest, mediumBlobTest, largeBlobTest];
var blobMaxTest = [maxBlobTest];
var bufferSourceTests = [emptyBufferSourceTest, smallBufferSourceTest, mediumBufferSourceTest, largeBufferSourceTest];
var bufferSourceMaxTest = [maxBufferSourceTest];
var formDataTests = [emptyFormDataTest, smallFormDataTest, mediumFormDataTest, largeFormDataTest];
var formDataMaxTest = [largeFormDataTest];
var allTests = [].concat(stringTests, stringMaxTest, blobTests, blobMaxTest, bufferSourceTests, bufferSourceMaxTest, formDataTests, formDataMaxTest);

// This special cross section of test cases is meant to provide a slimmer but reasonably-
// representative set of tests for parameterization across variables (e.g. redirect codes,
// cors modes, etc.)
var sampleTests = [noDataTest, nullDataTest, undefinedDataTest, smallStringTest, smallBlobTest, smallBufferSourceTest, smallFormDataTest];

// Build a test lookup table, which is useful when instructing a web worker or an iframe
// to run a test, so that we don't have to marshal the entire test case across a process boundary.
var testLookup = {};
allTests.forEach(function(testCase) {
    testLookup[testCase.id] = testCase;
});

// Helper function to create an ArrayBuffer representation of a string.
function CreateArrayBufferFromPayload(payload) {
    var length = payload.length;
    var buffer = new Uint8Array(length);

    for (var i = 0; i < length; i++) {
        buffer[i] = payload.charCodeAt(i);
    }

    return buffer;
}

// Helper function to create an empty FormData object.
function CreateEmptyFormDataPayload() {
    if (self.document === undefined) {
        return null;
    }

    return new FormData();
}

// Helper function to create a FormData representation of a string.
function CreateFormDataFromPayload(payload) {
    if (self.document === undefined) {
        return null;
    }

    var formData = new FormData();
    formData.append("payload", payload);
    return formData;
}

// Initializes a session with a client-generated SID.
// A "session" is a run of one or more tests. It is used to batch several beacon
// tests in a way that isolates the server-side session state and makes it easy
// to poll the results of the tests in one request.
//     testCases: The array of test cases participating in the session.
function initSession(testCases) {
    return {
        // Provides a unique session identifier to prevent mixing server-side data
        // with other sessions.
        id: self.token(),
        // Dictionary of test name to live testCase object.
        testCaseLookup: {},
        // Array of testCase objects for iteration.
        testCases: [],
        // Tracks the total number of tests in the session.
        totalCount: testCases.length,
        // Tracks the number of tests for which we have sent the beacon.
        // When it reaches totalCount, we will start polling for results.
        sentCount: 0,
        // Tracks the number of tests for which we have verified the results.
        // When it reaches sentCount, we will stop polling for results.
        doneCount: 0,
        // Helper to add a testCase to the session.
        add: function add(testCase) {
            this.testCases.push(testCase);
            this.testCaseLookup[testCase.id] = testCase;
        }
    };
}

// Schedules async_test's for each of the test cases, treating them as a single session,
// and wires up the continueAfterSendingBeacon() and waitForResults() calls.
// The method looks for several "extension" functions in the global scope:
//   - self.buildId: if present, can change the display name of a test.
//   - self.buildBaseUrl: if present, can change the base URL of a beacon target URL (this
//     is the scheme, hostname, and port).
//   - self.buildTargetUrl: if present, can modify a beacon target URL (for example wrap it).
// Parameters:
//     testCases: An array of test cases.
function runTests(testCases) {
    var session = initSession(testCases);

    testCases.forEach(function(testCase, testIndex) {
        // Make a copy of the test case as we'll be storing some metadata on it,
        // such as which session it belongs to.
        var testCaseCopy = Object.assign({ session: session }, testCase);

        // Extension point: generate the test id.
        var testId = testCase.id;
        if (self.buildId) {
            testId = self.buildId(testId);
        }
        testCaseCopy.origId = testCaseCopy.id;
        testCaseCopy.id = testId;
        testCaseCopy.index = testIndex;

        session.add(testCaseCopy);

        // Schedule the sendbeacon in an async test.
        async_test(function(test) {
            // Save the testharness.js 'test' object, so that we only have one object
            // to pass around.
            testCaseCopy.test = test;

            // Extension point: generate the beacon URL.
            var baseUrl = "http://{{host}}:{{ports[http][0]}}";
            if (self.buildBaseUrl) {
                baseUrl = self.buildBaseUrl(baseUrl);
            }
            var targetUrl = `${baseUrl}/beacon/resources/beacon.py?cmd=store&sid=${session.id}&tid=${testId}&tidx=${testIndex}`;
            if (self.buildTargetUrl) {
                targetUrl = self.buildTargetUrl(targetUrl);
            }
            // Attach the URL to the test object for debugging purposes.
            testCaseCopy.url = targetUrl;

            // Extension point: send the beacon immediately, or defer.
            var sendFunc = test.step_func(function sendImmediately(testCase) {
                var sendResult = sendData(testCase);
                continueAfterSendingBeacon(sendResult, testCase);
            });
            if (self.sendFunc) {
                sendFunc = test.step_func(self.sendFunc);
            }
            sendFunc(testCaseCopy);
        }, `Verify 'navigator.sendbeacon()' successfully sends for variant: ${testCaseCopy.id}`);
    });
}

// Sends the beacon for a single test. This step is factored into its own function so that
// it can be called from a web worker. It does not check for results.
// Note: do not assert from this method, as when called from a worker, we won't have the
// full testharness.js test context. Instead return 'false', and the main scope will fail
// the test.
// Returns the result of the 'sendbeacon()' function call, true or false.
function sendData(testCase) {
    var sent = false;
    if (testCase.data) {
        sent = self.navigator.sendBeacon(testCase.url, testCase.data);
    } else {
        sent = self.navigator.sendBeacon(testCase.url)
    }
    return sent;
}

// Continues a single test after the beacon has been sent for that test.
// Will trigger waitForResults() for the session if this is the last test
// in the session to send its beacon.
// Assumption: will be called on the test's step_func so that assert's do
// not have to be wrapped.
function continueAfterSendingBeacon(sendResult, testCase) {
    var session = testCase.session;

    // Recaclulate the sent vs. total counts.
    if (sendResult) {
        session.sentCount++;
    } else {
        session.totalCount--;
    }

    // If this was the last test in the session to send its beacon, start polling for results.
    // Note that we start polling even if just one test in the session sends successfully,
    // so that if any of the others fail, we still get results from the tests that did send.
    if (session.sentCount == session.totalCount) {
        // Exit the current test's execution context in order to run the poll
        // loop from the harness context.
        step_timeout(waitForResults.bind(this, session), 0);
    }

    // Now fail this test if the beacon did not send. It will be excluded from the poll
    // loop because of the calculation adjustment above.
    assert_true(sendResult, "'sendbeacon' function call must succeed");
}

// Kicks off an asynchronous monitor to poll the server for test results. As we
// verify that the server has received and validated a beacon, we will complete
// its testharness test.
function waitForResults(session) {
    // Poll for status until all of the results come in.
    fetch(`resources/beacon.py?cmd=stat&sid=${session.id}&tidx_min=0&tidx_max=${session.totalCount-1}`).then(
        function(response) {
            // Parse as text(), not json(), so that we can log the raw response if
            // it's invalid.
            response.text().then(function(rawResponse) {
                // Check that we got a response we expect and know how to handle.
                var results;
                var failure;
                try {
                    results = JSON.parse(rawResponse);

                    if (results.length === undefined) {
                        failure = `bad validation response schema: rawResponse='${rawResponse}'`;
                    }
                } catch (e) {
                    failure = `bad validation response: rawResponse='${rawResponse}', got parse error '${e}'`;
                }

                if (failure) {
                    // At this point we can't deterministically get results for all of the
                    // tests in the session, so fail the entire session.
                    failSession(session, failure);
                    return;
                }

                // The 'stat' call will return an array of zero or more results
                // of sendbeacon() calls that the server has received and validated.
                results.forEach(function(result) {
                    var testCase = session.testCaseLookup[result.id];

                    // While stash.take on the server is supposed to honor read-once, since we're
                    // polling so frequently it is possible that we will receive the same test result
                    // more than once.
                    if (!testCase.done) {
                        testCase.done = true;
                        session.doneCount++;
                    }

                    // Validate that the sendbeacon() was actually sent to the server.
                    var test = testCase.test;
                    test.step(function() {
                        // null JSON values parse as null, not undefined
                        assert_equals(result.error, null, "'sendbeacon' data must not fail validation");
                    });

                    test.done();
                });

                // Continue polling until all of the results come in.
                if (session.doneCount < session.sentCount) {
                    // testharness.js frowns upon the use of explicit timeouts, but there is no way
                    // around the need to poll for these tests, and there is no use spamming the server
                    // with requestAnimationFrame() just to avoid the use of step_timeout.
                    step_timeout(waitForResults.bind(this, session), 100);
                }
            }).catch(function(error) {
                failSession(session, `unexpected error reading response, error='${error}'`);
            });
        }
    );
}

// Fails all of the tests in the session, meant to be called when an infrastructural
// issue prevents us from deterministically completing the individual tests.
function failSession(session, reason) {
    session.testCases.forEach(function(testCase) {
        var test = testCase.test;
        test.unreached_func(reason)();
    });
}

// Creates an iframe on the document's body and runs the sample tests from the iframe.
// The iframe is navigated immediately after it sends the data, and the window verifies
// that the data is still successfully sent.
//    funcName: "beacon" to send the data via navigator.sendBeacon(),
//              "fetch" to send the data via fetch() with the keepalive flag.
function runSendInIframeAndNavigateTests(funcName) {
    var iframe = document.createElement("iframe");
    iframe.id = "iframe";
    iframe.onload = function() {
        var tests = Array();

        // Clear our onload handler to prevent re-running the tests as we navigate away.
        this.onload = null;

        // Implement the self.buildId extension to identify the parameterized
        // test in the report.
        self.buildId = function(baseId) {
            return `${baseId}-${funcName}-NAVIGATE`;
        };

        window.onmessage = function(e) {
            // The iframe will execute sendData() for us and return the result.
            var testCase = tests[e.data];
            continueAfterSendingBeacon(true /* sendResult */, testCase);
        };

        // Implement the self.sendFunc extension to send the beacon indirectly,
        // from an iFrame that we can then navigate.
        self.sendFunc = function(testCase) {
            var iframeWindow = document.getElementById("iframe").contentWindow;
            // We run into problems passing the testCase over the document boundary,
            // because of structured cloning constraints. Instead we'll send over the
            // test case id, and the iFrame can load the static test case by including
            // beacon-common.js.
            tests[testCase.origId] = testCase;
            iframeWindow.postMessage([testCase.origId, testCase.url, funcName], "*");
        };

        runTests(sampleTests);
    };

    iframe.src = "navigate.iFrame.sub.html";
    document.body.appendChild(iframe);
}
