only-one-tab
Advanced tools
Comparing version 1.0.0 to 1.0.1
@@ -44,6 +44,8 @@ /* global localStorage */ | ||
// localStorage.setItem(vacantKey, 'vacant') signals the actor tab closed | ||
window.addEventListener('storage', async (event) => { | ||
window.addEventListener('storage', async function handler (event) { | ||
if (event.key === vacantKey && event.newValue === 'vacant') { | ||
if (await race(actorRaceId)) { | ||
becomeActor() | ||
window.removeEventListener('storage', handler) // cleanup | ||
} | ||
@@ -53,24 +55,46 @@ } | ||
// try to act initially | ||
// if there's already an actor, the race will be lost | ||
if (await race(actorRaceId)) { | ||
becomeActor() | ||
// reset if the last active tab closed without ending the actor race somehow | ||
} else if (isTimedOut()) { | ||
// multiple tabs may try to reset at once, so a race is necessary | ||
if (await race(resetRaceId)) { | ||
// no need to end the actorRace because this tab replaces the old winner | ||
// check if this tab should become actor | ||
// on start and then periodically to ensure nothing gets stuck | ||
// sometimes the 'storage' event doesn't fire | ||
// somtimes the actorRace randomly gets stuck | ||
while (true) { | ||
// actorRace lasts until actor tab is closed | ||
if (await race(actorRaceId)) { | ||
becomeActor() | ||
// wait for heartbeat to start (no resets with active heartbeat) | ||
// also wait for any other resetters to finish to prevent multiple actors | ||
await sleep(1000) | ||
endRace(resetRaceId) | ||
break // end periodic check since we're the actor now | ||
// reset if the last active tab closed without ending the actor race somehow | ||
} else if (isTimedOut()) { | ||
// multiple tabs may try to reset at once, so a race is necessary | ||
if (await race(resetRaceId)) { | ||
// no need to end the actorRace because this tab replaces the old winner | ||
becomeActor() | ||
// wait for heartbeat to start (no resets with active heartbeat) | ||
// also wait for any other resetters to finish to prevent multiple actors | ||
await sleep(1000) | ||
endRace(resetRaceId) | ||
break // end periodic check since we're the actor now | ||
} | ||
} | ||
// indexedDB operations are expensive, and things only get stuck very rarely | ||
await sleep(5 * 1000) | ||
} | ||
// returns a boolean for whether there is an active tab | ||
// returns a boolean for whether an old actor tab crashed | ||
// (closed without cleaning up and signalling for a new actor) | ||
function isTimedOut () { | ||
const lastHeartbeatString = localStorage.getItem(heartbeatKey) | ||
// if the key is null, then there was no previous actor. | ||
// not checking for this allows multiple actors when multiple tabs are | ||
// opened before the first actor makes a heartbeat | ||
// one wins the race and another wins the unnecessary reset | ||
if (lastHeartbeatString === null) { | ||
return false | ||
} | ||
const msSinceLastHeartbeat = new Date() - new Date(lastHeartbeatString) | ||
@@ -77,0 +101,0 @@ |
{ | ||
"name": "only-one-tab", | ||
"version": "1.0.0", | ||
"version": "1.0.1", | ||
"description": "Run a function in exactly one open tab, switching to another if closed", | ||
@@ -5,0 +5,0 @@ "main": "only-one-tab.js", |
212
test.js
@@ -11,2 +11,8 @@ // This Source Code Form is subject to the terms of the Mozilla Public | ||
// rejects after 10 seconds | ||
async function rejectAfter10s (error) { | ||
await sleep(10 * 1000) | ||
throw error | ||
} | ||
async function runOnPage (page, asyncFunc) { | ||
@@ -31,2 +37,8 @@ // this html file loads ./bundle.js which is ./only-one-tab.js browserified | ||
// returns tab object | ||
// { | ||
// page: (puppeteer page for this tab), | ||
// isActing: (has the onlyOneTab callback been run yet?) | ||
// actingPromise: (promise for when the onlyOneTab callback runs) | ||
// } | ||
async function newTab (browser) { | ||
@@ -40,6 +52,13 @@ const page = await browser.newPage() | ||
tab.promise = runOnPage(page, async () => { | ||
tab.actingPromise = runOnPage(page, async () => { | ||
const onlyOneTab = require('only-one-tab') | ||
await new Promise((resolve) => onlyOneTab(resolve)) | ||
await new Promise((resolve) => { | ||
onlyOneTab(() => { | ||
// console logs for debugging with devtools | ||
console.log('this tab started acting at ', new Date()) | ||
resolve() | ||
}) | ||
}) | ||
}) | ||
@@ -55,26 +74,25 @@ // .then is required because the above async function runs on the page not here | ||
test('open 20 tabs, then close each actor tab in sequence', async () => { | ||
test('a closing tab should successfully signal a new actor', async () => { | ||
const browser = await puppeteer.launch() | ||
// repeat for consistency | ||
for (let i = 0; i < 10; i++) { | ||
const tabs = [] | ||
const tabs = [] | ||
for (let i = 0; i < 20; i++) { | ||
const tab = await newTab(browser) | ||
tabs.push(tab) | ||
} | ||
// start with one tab | ||
tabs.push(await newTab(browser)) | ||
while (tabs.length > 0) { | ||
// wait for a tab to become actor | ||
await Promise.race(tabs.map((tab) => tab.promise)) | ||
// repeat to ensure consistency | ||
for (let i = 0; i < 100; i++) { | ||
// add one tab | ||
tabs.push(await newTab(browser)) | ||
const actingTabs = tabs.filter((tab) => tab.isActing === true) | ||
assert.equal(actingTabs.length, 1, 'there should be exactly one actor') | ||
// wait for a tab to become actor | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to become actor')), | ||
...tabs.map((tab) => tab.actingPromise) | ||
]) | ||
// close the acting tab and remove from tabs[] | ||
const actingIndex = actingTabs.findIndex((tab) => tab.isActing === true) | ||
await tabs[actingIndex].page.close({ runBeforeUnload: true }) | ||
tabs.splice(actingIndex, 1) | ||
} | ||
// close the acting tab and remove from tabs[] | ||
const actingIndex = tabs.findIndex((tab) => tab.isActing === true) | ||
await tabs[actingIndex].page.close({ runBeforeUnload: true }) | ||
tabs.splice(actingIndex, 1) | ||
} | ||
@@ -85,35 +103,59 @@ | ||
test('open 20 tabs, then close them randomly', async () => { | ||
test('there should be only one actor after opening many tabs concurrently', async () => { | ||
const browser = await puppeteer.launch() | ||
// repeat for consistency | ||
for (let i = 0; i < 10; i++) { | ||
const tabs = [] | ||
const openingTabs = [] | ||
for (let i = 0; i < 200; i++) { | ||
// open in parallel to allow them to compete | ||
openingTabs.push(newTab(browser)) | ||
} | ||
for (let i = 0; i < 20; i++) { | ||
const tab = await newTab(browser) | ||
tabs.push(tab) | ||
} | ||
const tabs = await Promise.all(openingTabs) | ||
while (tabs.length > 0) { | ||
// wait until any tab is an actor | ||
await Promise.race(tabs.map((tab) => tab.promise)) | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to become actor')), | ||
...tabs.map((tab) => tab.actingPromise) | ||
]) | ||
const actingTabs = tabs.filter((tab) => tab.isActing === true) | ||
assert.equal(actingTabs.length, 1, 'there should be exactly one actor') | ||
const actingTabs = tabs.filter((tab) => tab.isActing === true) | ||
assert.equal(actingTabs.length, 1, 'there should be exactly one actor') | ||
const randomTabIndex = Math.floor(Math.random() * tabs.length) | ||
browser.close() | ||
}) | ||
// close random tab | ||
await tabs[randomTabIndex].page.close({ runBeforeUnload: true }) | ||
// this reliably tests recovery from getting stuck | ||
test('close all but one non-acting tab at once', async () => { | ||
const browser = await puppeteer.launch() | ||
// remove closed tab from tabs[] | ||
tabs.splice(randomTabIndex, 1) | ||
} | ||
// open 99 tabs | ||
const tabPromises = [] | ||
for (let i = 0; i < 99; i++) { | ||
tabPromises.push(newTab(browser)) | ||
} | ||
const tabsToClose = await Promise.all(tabPromises) | ||
// wait for an actor | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for initial tab to become actor')), | ||
...tabsToClose.map((tab) => tab.actingPromise) | ||
]) | ||
const nonActor = await newTab(browser) | ||
// close all tabs but nonActor concurrently | ||
for (const tab of tabsToClose) { | ||
tab.page.close({ runBeforeUnload: true }) | ||
} | ||
// wait for nonActor to act | ||
await Promise.race([ | ||
rejectAfter10s(new Error('last tab should act')), | ||
nonActor.actingPromise | ||
]) | ||
browser.close() | ||
}) | ||
test('should reset after last tab closed', async () => { | ||
test('should work when site is reopened', async () => { | ||
const browser = await puppeteer.launch() | ||
@@ -124,15 +166,16 @@ | ||
// wait for action | ||
await tab.promise | ||
await tab.actingPromise | ||
await tab.page.close({ runBeforeUnload: true }) | ||
// make sure the last tab fully closes | ||
await sleep(100) | ||
const tab2 = await newTab(browser) | ||
async function timeout () { | ||
await sleep(10 * 1000) | ||
throw new Error('timed out') | ||
} | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to act')), | ||
tab2.actingPromise | ||
]) | ||
await Promise.race([timeout(), tab2.promise]) | ||
browser.close() | ||
@@ -147,3 +190,3 @@ }) | ||
// wait until crashTab is the actor | ||
await crashTab.promise | ||
await crashTab.actingPromise | ||
@@ -161,3 +204,3 @@ crashTab.page.on('error', () => {}) // this prevents unhandled rejection from crash | ||
for (let i = 0; i < 100; i++) { | ||
// create all tabs in parallel to allow them to compete for actor | ||
// open many tabs concurrently to ensure that the reset doesn't cause multiple actors | ||
tabPromises.push(newTab(browser)) | ||
@@ -168,5 +211,11 @@ } | ||
// wait 10 seconds to ensure everything is settled | ||
await sleep(10 * 1000) | ||
// wait for an actor | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to act')), | ||
...tabs.map((tab) => tab.actingPromise) | ||
]) | ||
// wait for any other tabs to start acting | ||
await sleep(2 * 1000) | ||
const actors = tabs.filter((tab) => tab.isActing === true) | ||
@@ -177,1 +226,62 @@ assert.equal(actors.length, 1, 'there should be exactly one actor') | ||
}) | ||
test('open a lot of tabs, then close each actor tab in sequence', async () => { | ||
const browser = await puppeteer.launch() | ||
const tabPromises = [] | ||
for (let i = 0; i < 100; i++) { | ||
tabPromises.push(newTab(browser)) | ||
} | ||
const tabs = await Promise.all(tabPromises) | ||
while (tabs.length > 0) { | ||
// wait for a tab to become actor | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to become actor')), | ||
...tabs.map((tab) => tab.actingPromise) | ||
]) | ||
const actingTabs = tabs.filter((tab) => tab.isActing === true) | ||
assert.equal(actingTabs.length, 1, 'there should be exactly one actor') | ||
// close the acting tab and remove from tabs[] | ||
const actingIndex = actingTabs.findIndex((tab) => tab.isActing === true) | ||
await tabs[actingIndex].page.close({ runBeforeUnload: true }) | ||
tabs.splice(actingIndex, 1) | ||
} | ||
browser.close() | ||
}) | ||
test('open a lot of tabs, then close them randomly', async () => { | ||
const browser = await puppeteer.launch() | ||
const tabPromises = [] | ||
for (let i = 0; i < 100; i++) { | ||
tabPromises.push(newTab(browser)) | ||
} | ||
const tabs = await Promise.all(tabPromises) | ||
while (tabs.length > 0) { | ||
// wait until any tab is an actor | ||
await Promise.race([ | ||
rejectAfter10s(new Error('timed out waiting for new tab to become actor')), | ||
...tabs.map((tab) => tab.actingPromise) | ||
]) | ||
const actingTabs = tabs.filter((tab) => tab.isActing === true) | ||
assert.equal(actingTabs.length, 1, 'there should be exactly one actor') | ||
const randomTabIndex = Math.floor(Math.random() * tabs.length) | ||
// close random tab | ||
await tabs[randomTabIndex].page.close({ runBeforeUnload: true }) | ||
// remove closed tab from tabs[] | ||
tabs.splice(randomTabIndex, 1) | ||
} | ||
browser.close() | ||
}) |
30497
290