diff --git a/src/pkjs/index.js b/src/pkjs/index.js index bbfcbed..d4183a7 100644 --- a/src/pkjs/index.js +++ b/src/pkjs/index.js @@ -5,6 +5,7 @@ var CONFIG_BASE_URL = "plexusBaseUrl"; var CONFIG_ADMIN_KEY = "plexusAdminKey"; var ERROR_KIND_RETRYABLE = 0; var ERROR_KIND_SETUP_REQUIRED = 1; +var REQUEST_TIMEOUT_MILLISECONDS = 20000; var clay = new Clay( createConfiguration( localStorage.getItem(CONFIG_BASE_URL) || "", @@ -17,6 +18,10 @@ var MAX_ROWS_PER_GROUP = 4; var quotaGroups = []; var currentPageIndex = 0; var providerDisplayNames = {}; +var configurationRevision = 0; +var refreshInFlight = false; +var refreshPending = false; +var pendingPageIndex; var ROW_KEYS = [ { @@ -62,17 +67,32 @@ function sendError(kind, message) { function xhrRequest(url, adminKey, callback) { var xhr = new XMLHttpRequest(); + var completed = false; + + function finish(error, responseText) { + if (completed) return; + completed = true; + callback(error, responseText); + } + xhr.onload = function () { if (xhr.status < 200 || xhr.status >= 300) { - callback(new Error("HTTP " + xhr.status), null); + finish(new Error("HTTP " + xhr.status), null); return; } - callback(null, xhr.responseText); + finish(null, xhr.responseText); }; xhr.onerror = function () { - callback(new Error("Network error"), null); + finish(new Error("Network error"), null); + }; + xhr.ontimeout = function () { + finish(new Error("Network error"), null); + }; + xhr.onabort = function () { + finish(new Error("Network error"), null); }; xhr.open("GET", url); + xhr.timeout = REQUEST_TIMEOUT_MILLISECONDS; xhr.setRequestHeader("x-admin-key", adminKey); xhr.send(); } @@ -513,14 +533,20 @@ function refreshQuotaWithDisplayNames( baseUrl, adminKey, pageIndex, - displayNames + displayNames, + revision ) { xhrRequest( baseUrl + "/v0/management/quotas", adminKey, function (error, responseText) { + if (revision !== configurationRevision) { + completeRefresh(); + return; + } if (error) { sendError(ERROR_KIND_RETRYABLE, error.message); + completeRefresh(); return; } try { @@ -529,11 +555,22 @@ function refreshQuotaWithDisplayNames( } catch (e) { sendError(ERROR_KIND_RETRYABLE, "Bad quota response"); } + completeRefresh(); } ); } -function refreshQuota(pageIndex) { +function completeRefresh() { + refreshInFlight = false; + if (!refreshPending) return; + + var pageIndex = pendingPageIndex; + refreshPending = false; + pendingPageIndex = undefined; + refreshQuota(pageIndex); +} + +function startRefresh(pageIndex) { var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL)); var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY); if (!baseUrl || !adminKey) { @@ -541,10 +578,16 @@ function refreshQuota(pageIndex) { return; } + refreshInFlight = true; + var revision = configurationRevision; xhrRequest( baseUrl + "/v0/management/providers", adminKey, function (providerError, providerResponseText) { + if (revision !== configurationRevision) { + completeRefresh(); + return; + } var displayNames = providerDisplayNames; try { if (!providerError) { @@ -554,11 +597,26 @@ function refreshQuota(pageIndex) { } catch (e) { displayNames = providerDisplayNames; } - refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames); + refreshQuotaWithDisplayNames( + baseUrl, + adminKey, + pageIndex, + displayNames, + revision + ); } ); } +function refreshQuota(pageIndex) { + if (refreshInFlight) { + refreshPending = true; + pendingPageIndex = pageIndex; + return; + } + startRefresh(pageIndex); +} + function configurationValues(fields) { var baseUrl = fields[CONFIG_BASE_URL]; var adminKey = fields[CONFIG_ADMIN_KEY]; @@ -577,7 +635,7 @@ function configurationValues(fields) { } Pebble.addEventListener("ready", function () { - refreshQuota(); + // The watch owns startup refresh so phone readiness cannot race its request. }); Pebble.addEventListener("appmessage", function (e) { @@ -603,6 +661,7 @@ Pebble.addEventListener("webviewclosed", function (e) { var values = configurationValues(clay.getSettings(e.response, false)); localStorage.setItem(CONFIG_BASE_URL, values.baseUrl); localStorage.setItem(CONFIG_ADMIN_KEY, values.adminKey); + configurationRevision += 1; clay.setSettings({ plexusBaseUrl: values.baseUrl, plexusAdminKey: values.adminKey diff --git a/test/pkjs.test.js b/test/pkjs.test.js index 64187cf..aa92a55 100644 --- a/test/pkjs.test.js +++ b/test/pkjs.test.js @@ -60,6 +60,7 @@ function loadPebbleKit(initialValues) { function FakeXMLHttpRequest() { this.headers = {}; + this.timeout = 0; requests.push(this); } @@ -76,6 +77,20 @@ function loadPebbleKit(initialValues) { this.sent = true; }; + FakeXMLHttpRequest.prototype.respond = function (status, responseText) { + this.status = status; + this.responseText = responseText; + this.onload(); + }; + + FakeXMLHttpRequest.prototype.fail = function () { + this.onerror(); + }; + + FakeXMLHttpRequest.prototype.triggerTimeout = function () { + this.ontimeout(); + }; + var context = { XMLHttpRequest: FakeXMLHttpRequest, localStorage: storage, @@ -248,6 +263,87 @@ test("network failures remain retryable", function () { ]); }); +test("phone readiness waits for the watch to request quotas", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.ready(); + + assert.deepEqual(kit.requests, []); + assert.deepEqual(kit.sentMessages, []); +}); + +test("refresh triggers coalesce behind one active request", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + + assert.equal(kit.requests.length, 1); + kit.requests[0].respond(200, "{}"); + assert.equal(kit.requests.length, 2); + kit.requests[0].fail(); + assert.equal(kit.requests.length, 2); + kit.requests[1].respond(200, "[]"); + assert.equal(kit.requests.length, 3); + assert.equal( + kit.requests[2].url, + "https://plexus.example/v0/management/providers" + ); +}); + +test("requests time out once and ignore later terminal events", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + assert.equal(kit.requests[0].timeout, 20000); + kit.requests[0].triggerTimeout(); + assert.equal(kit.requests.length, 2); + assert.equal(kit.requests[1].timeout, 20000); + kit.requests[1].triggerTimeout(); + kit.requests[1].fail(); + + assert.deepEqual(JSON.parse(JSON.stringify(kit.sentMessages)), [ + { ERROR: "Network error", ERROR_KIND: ERROR_KIND_RETRYABLE } + ]); +}); + +test("credential replacement ignores the active refresh", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://old.example", + plexusAdminKey: "old-secret" + }); + var response = encodeURIComponent( + JSON.stringify({ + plexusBaseUrl: { value: "https://new.example" }, + plexusAdminKey: { value: "new-secret" } + }) + ); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.webviewclosed({ response: response }); + assert.equal(kit.requests.length, 1); + + kit.requests[0].respond(200, '{"old":{"displayName":"Old"}}'); + + assert.equal(kit.requests.length, 2); + assert.equal( + kit.requests[1].url, + "https://new.example/v0/management/providers" + ); + assert.equal(kit.requests[1].headers["x-admin-key"], "new-secret"); + assert.deepEqual(kit.sentMessages, []); +}); + test("phone-only configuration keys are not AppMessage keys", function () { var packageJson = require(path.join(__dirname, "..", "package.json"));