From ca01584427a21fcf1f0a91e086a939dcd20834d2 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:48:49 +0100 Subject: [PATCH] A late failure of the last source's player stays its own; one list says who may message the background setSource counts the next source at once, and the last source's player stays the player until resetPlayer tears it down. What it reported in between was taken for the new source's: its hand-over to Firefox's own player played the old URL over the source the user had just picked, its error and NEED_KEY showed the load error over it, and a refused link of the old source was asked for anew and rebuilt over it. tryNextSource and recoverPlayer checked this already; every failure route does now (FastStreamClient.isCurrentAttempt; renewSourceLink also after the background's answer). The background's runtime.onMessage answered 33 message types, and some checked their sender where they were answered while others did not: LOAD_OPTIONS, MPV_TEST, CLEAR_SOURCES and REQUEST_PLAYLIST_NAVIGATION, which only the options page or the player send, did what any content script asked. MessageSenders now says for every type whether FastStream's own pages only or its content scripts too may send it, read before any handler, in place of the five inline checks. backgroundMessages.test.mjs keeps it complete: every answered type listed, none a content script sends marked own-pages-only. The test harness sends an own-pages message from the player page unless a test names another sender. Co-Authored-By: Claude Opus 5.5 --- chrome/background/background.mjs | 78 +++++++++++++----- chrome/player/FastStreamClient.mjs | 26 +++++- tests/unit/FastStreamClient.test.mjs | 86 ++++++++++++++++++++ tests/unit/backgroundAutoplayNext.test.mjs | 5 +- tests/unit/backgroundDetection.test.mjs | 7 ++ tests/unit/backgroundHarness.mjs | 9 ++- tests/unit/backgroundMessages.test.mjs | 93 ++++++++++++++++++++++ tests/unit/backgroundRobustness.test.mjs | 2 +- 8 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 tests/unit/backgroundMessages.test.mjs diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 5203ae02e..cddabc3a7 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -957,25 +957,67 @@ function fromOwnPage(sender) { return String(sender.url || '').startsWith(chrome.runtime.getURL('')); } +// Who may send each message the listener below answers. 'own': FastStream's own pages only +// (the player, also framed in a site, and the options page: fromOwnPage). 'any': its content +// scripts too, which run in every page. A content script sends none of the 'own' ones, so one +// that does is not FastStream's to obey: what they do - the options read anew, the mpv test, +// header rules, downloads, the tab's sources cleared, the next episode - is the player's or +// the options page's to ask for. Some of them checked their sender where they were answered and +// others not (LOAD_OPTIONS, MPV_TEST, CLEAR_SOURCES, REQUEST_PLAYLIST_NAVIGATION: glm's review, +// 2026-10-28); every type the listener answers is listed here (backgroundMessages.test.mjs). +/** @type {Object} */ +export const MessageSenders = { + [MessageTypes.PING]: 'own', + [MessageTypes.VPN_STATUS]: 'own', + [MessageTypes.VPN_OPEN_PERMISSION]: 'own', + [MessageTypes.LOAD_OPTIONS]: 'own', + [MessageTypes.MPV_TEST]: 'own', + [MessageTypes.MPV_OPEN]: 'own', + [MessageTypes.PLAYER_LOADED]: 'own', + [MessageTypes.PLAYER_PLAYING]: 'own', + [MessageTypes.REQUEST_FRESH_LINK]: 'own', + [MessageTypes.BUFFER_STASH]: 'own', + [MessageTypes.BUFFER_TAKE]: 'own', + [MessageTypes.REQUEST_SOURCES]: 'own', + [MessageTypes.CLEAR_SOURCES]: 'own', + [MessageTypes.SET_HEADERS]: 'own', + [MessageTypes.DOWNLOAD]: 'own', + [MessageTypes.REQUEST_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_WINDOWED_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_KEYBOARD]: 'own', + [MessageTypes.REQUEST_WINDOW_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_MINIPLAYER]: 'own', + [MessageTypes.REQUEST_PLAYLIST_NAVIGATION]: 'own', + [MessageTypes.REPORT_AUTOPLAY_PROBLEM]: 'own', + [MessageTypes.REQUEST_PLAYLIST_POLL]: 'own', + [MessageTypes.POPUP_GUARD_ARM]: 'any', + [MessageTypes.MPV_USER_PLAY]: 'any', + [MessageTypes.SHORTCUT_CANCELLED]: 'any', + [MessageTypes.FRAME_ADDED]: 'any', + [MessageTypes.FRAME_REMOVED]: 'any', + [MessageTypes.PLAYER_OPEN_GONE]: 'any', + [MessageTypes.LOADED_MEDIA]: 'any', + [MessageTypes.DETECTED_SOURCE]: 'any', + [MessageTypes.SEND_TO_PLAYER]: 'any', + [MessageTypes.EXIT_CARRIED_FULLSCREEN]: 'any', +}; + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (MessageSenders[msg?.type] === 'own' && !fromOwnPage(sender)) { + return; + } if (msg.type === MessageTypes.PING) { sendResponse(MessageTypes.PONG); return; } else if (msg.type === MessageTypes.VPN_STATUS) { // The player, about its source: did the page reach it through Firefox VPN, and may - // FastStream follow (VpnProxyMirror)? Only from FastStream's own pages: the answer adds - // the URL's host to those that follow the VPN. - if (!fromOwnPage(sender)) { - return; - } + // FastStream follow (VpnProxyMirror)? Only from FastStream's own pages (MessageSenders): + // the answer adds the URL's host to those that follow the VPN. sendResponse(VpnMirror.status(String(msg.url || ''), !!sender.tab?.incognito, sender.tab?.id)); return; } else if (msg.type === MessageTypes.VPN_OPEN_PERMISSION) { // The player's Firefox VPN button: a player in a page has no permissions API, so the // permissions page asks, at its proxy row, in a tab of its own next to the video's. - if (!fromOwnPage(sender)) { - return; - } /** @type {chrome.tabs.CreateProperties} */ const create = {url: chrome.runtime.getURL('perms.html') + '#proxy'}; if (sender.tab?.id !== undefined) { @@ -1028,11 +1070,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // seconds are swallowed: each one spawns its own mpv window, and a user // who clicks again because nothing appeared yet should not end up with a // stack of them. - // Only from the player: a manual send skips the MPV allowlist, and this one names the - // URL, headers and subtitles the helper gets (#470: a content script could send it). - if (!fromOwnPage(sender)) { - return; - } + // Only from the player (MessageSenders): a manual send skips the MPV allowlist, and this + // one names the URL, headers and subtitles the helper gets (#470: a content script could + // send it). if (Logging) console.log('[MPV] MPV_OPEN request:', msg.url); const now = Date.now(); if (msg.url === LastManualMpvUrl && @@ -1268,10 +1308,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse('cleared'); return; } else if (msg.type === MessageTypes.SET_HEADERS) { - // Only from FastStream's own pages: the rule sets the headers the message names (#470). - if (!fromOwnPage(sender)) { - return; - } + // Only from FastStream's own pages (MessageSenders): the rule sets the headers the + // message names (#470). if (msg.commands.length) { // A tab that sends a message has an id. The rule's lifetime is capped (RuleEntry). ruleManager.addHeaderRule(msg.url, /** @type {number} */ (senderTab.id), msg.commands, msg.lifetimeMs).then((rule) => { @@ -1296,10 +1334,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }, frame, mode); } } else if (msg.type === MessageTypes.DOWNLOAD) { - // Only from FastStream's own pages: it downloads whatever URL the message names (#470). - if (!fromOwnPage(sender)) { - return; - } + // Only from FastStream's own pages (MessageSenders): it downloads whatever URL the + // message names (#470). const url = msg.url; // Firefox refuses a name with a colon and some other characters, and the download then // silently did not happen: every screenshot ("@00:05") and any title with a colon. diff --git a/chrome/player/FastStreamClient.mjs b/chrome/player/FastStreamClient.mjs index c7a51d782..45b2545b3 100644 --- a/chrome/player/FastStreamClient.mjs +++ b/chrome/player/FastStreamClient.mjs @@ -2040,6 +2040,20 @@ export class FastStreamClient extends EventEmitter { return true; } + /** + * Whether a player's failure is about the source being set now. setSource counts the next + * source at once, and the last source's player stays this.player until resetPlayer tears it + * down: what it reported in between was taken for the new source's - its hand-over to + * Firefox's own player played the old URL over the one the user had just picked, and its + * error showed the load error over the source on its way in (glm's review, 2026-10-28). + * tryNextSource and recoverPlayer asked this already; every failure route now does. + * @param {Player} player - The player whose event it is. + * @return {boolean} + */ + isCurrentAttempt(player) { + return player === this.player && this.fallbacks.request === this.sourceRequests; + } + /** * A source's URL without the time recoverPlayer gives it, so its recoveries count as one * source's. @@ -2072,7 +2086,10 @@ export class FastStreamClient extends EventEmitter { */ async renewSourceLink(status) { const source = this.source; - if (!source || !EnvUtils.isExtension() || source.loadedFromArchive || !/^https?:/i.test(source.url)) { + // A refusal of the last source's request, with the next one on its way in, is none of it. + const request = this.sourceRequests; + if (!source || this.fallbacks.request !== request || !EnvUtils.isExtension() || source.loadedFromArchive || + !/^https?:/i.test(source.url)) { return false; } const now = Date.now(); @@ -2096,8 +2113,8 @@ export class FastStreamClient extends EventEmitter { } finally { renewals.asking = false; } - // Another video meanwhile, or the page has no other link. - if (this.source !== source || typeof fresh !== 'string') { + // Another video meanwhile (asked for, or already set), or the page has no other link. + if (this.source !== source || this.sourceRequests !== request || typeof fresh !== 'string') { return false; } const time = this.currentTime; @@ -2485,6 +2502,7 @@ export class FastStreamClient extends EventEmitter { // A source this player cannot load (MP4Player, a server that ignores Range): Firefox's own // player plays it, once per source. this.context.on(DefaultPlayerEvents.PLAY_DIRECTLY, (/** @type {string} */ reason) => { + if (!this.isCurrentAttempt(player)) return; if (!this.playDirectly(player, reason)) { const message = Localize.getMessage('player_error_load'); this.failedToLoad(message + ' (' + reason + ')'); @@ -2493,6 +2511,7 @@ export class FastStreamClient extends EventEmitter { this.context.on(DefaultPlayerEvents.ERROR, (/** @type {unknown} */ reason) => { console.error('ERROR', reason); + if (!this.isCurrentAttempt(player)) return; // A video codec that just failed to decode for good: the same source again, without it. // Only a DashPlayer has takeCodecReload. if (/** @type {Player & {takeCodecReload?: () => boolean}} */ (player).takeCodecReload?.() && this.reloadWithoutFailedCodec(player)) { @@ -2514,6 +2533,7 @@ export class FastStreamClient extends EventEmitter { }); this.context.on(DefaultPlayerEvents.NEED_KEY, (/** @type {undefined} */ event) => { + if (!this.isCurrentAttempt(player)) return; this.failedToLoad(Localize.getMessage('player_error_drm')); }); diff --git a/tests/unit/FastStreamClient.test.mjs b/tests/unit/FastStreamClient.test.mjs index 13b12032f..bc1ee92e0 100644 --- a/tests/unit/FastStreamClient.test.mjs +++ b/tests/unit/FastStreamClient.test.mjs @@ -1979,3 +1979,89 @@ describe('FastStreamClient, a link the server refuses', () => { expect(asked()).toHaveLength(3); }); }); + +// setSource counts the next source at once, and the last source's player stays the player until +// resetPlayer tears it down. A failure it reported in between was taken for the new source's: +// its hand-over to Firefox's own player played the old URL over the source the user had just +// picked, its error showed the load error over it, and a refused link of the old source was +// asked for anew and rebuilt over it (glm's review, 2026-10-28). +describe('FastStreamClient, the last source\'s player failing while the next source is on its way in', () => { + const nextSourceAskedFor = (client) => { + client.sourceRequests++; + }; + + it('does not hand the last source to Firefox\'s own player', async () => { + const client = makeClient(); + const old = await setSource(client, makeSource('https://cdn.example/old.mp4', {mode: 'accelerated_mp4'})); + const set = vi.spyOn(client, 'setSource'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + nextSourceAskedFor(client); + + old.emit(DefaultPlayerEvents.PLAY_DIRECTLY, 'The file is not an MP4 file'); + await settle(); + + expect(set).not.toHaveBeenCalled(); + expect(client.interfaceController.failedToLoad).not.toHaveBeenCalled(); + }); + + it('shows no load error for its error, nor for a key it lacks', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = makeClient(); + const old = await setSource(client, makeSource('https://cdn.example/old.m3u8')); + nextSourceAskedFor(client); + + old.emit(DefaultPlayerEvents.ERROR, {}); + old.emit(DefaultPlayerEvents.NEED_KEY); + await settle(); + + expect(client.interfaceController.failedToLoad).not.toHaveBeenCalled(); + }); + + it('still fails as before while its source is the one being played', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = makeClient(); + const current = await setSource(client, makeSource('https://cdn.example/a.m3u8')); + + current.emit(DefaultPlayerEvents.NEED_KEY); + await settle(); + + expect(client.interfaceController.failedToLoad).toHaveBeenCalledExactlyOnceWith('player_error_drm'); + }); + + it('does not ask for the last source\'s link anew, nor rebuild it with one that comes late', async () => { + const OLD = 'https://cdn.example/hls/master.m3u8?sig=old'; + const client = makeClient(); + client.linkRenewals = {identifier: null, times: [], asking: false}; + client.downloadManager.keepStorageOnce = vi.fn(); + client.downloadManager.relink = vi.fn(); + await setSource(client, makeSource(OLD)); + const built = vi.fn(); + client.playerLoader.createPlayer = built; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const asked = () => chrome.runtime.sendMessage.mock.calls.filter(([message]) => message.type === MessageTypes.REQUEST_FRESH_LINK); + + nextSourceAskedFor(client); + client.linkRefused(403, OLD); + await settle(); + expect(asked()).toEqual([]); + + // Refused while it was the source; the next one asked for before the new link came. + client.fallbacks.request = client.sourceRequests; + let answer; + chrome.runtime.sendMessage.mockImplementation((message) => (message.type === MessageTypes.REQUEST_FRESH_LINK ? + new Promise((resolve) => { + answer = resolve; + }) : Promise.resolve())); + client.linkRefused(403, OLD); + await settle(); + expect(asked()).toHaveLength(1); + nextSourceAskedFor(client); + answer('https://cdn.example/hls/master.m3u8?sig=new'); + await settle(); + // A rebuild would be under way there (the first case's wait). + await client.sourceChange; + await settle(); + expect(built).not.toHaveBeenCalled(); + expect(client.source.url).toBe(OLD); + }); +}); diff --git a/tests/unit/backgroundAutoplayNext.test.mjs b/tests/unit/backgroundAutoplayNext.test.mjs index 2bbd20031..7306f7ec0 100644 --- a/tests/unit/backgroundAutoplayNext.test.mjs +++ b/tests/unit/backgroundAutoplayNext.test.mjs @@ -89,8 +89,9 @@ describe('the player\'s autoplay next', () => { await restarted(session); expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: PLAYER_IN_PAGE})).toBe('error'); expect(await bg.message(NEXT, {tabId: 1, frameId: 6, url: PLAYER_IN_PAGE})).toBe('error'); - // Nor for the page itself in the player's frame: only FastStream's player page names it. - expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: EP1})).toBe('error'); + // Nor for the page itself in the player's frame: only FastStream's player page names it, + // and a content script's request goes unanswered (MessageSenders). + expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: EP1})).toBe(undefined); expect(bg.sent('PLAYLIST_NAVIGATION')).toEqual([]); }); diff --git a/tests/unit/backgroundDetection.test.mjs b/tests/unit/backgroundDetection.test.mjs index 7eeacae50..901b8ed93 100644 --- a/tests/unit/backgroundDetection.test.mjs +++ b/tests/unit/backgroundDetection.test.mjs @@ -140,6 +140,13 @@ describe('what a player was handed', () => { await bg.message({type: 'CLEAR_SOURCES'}, {tabId: 1, frameId: 5}); expect(await askAgain()).toEqual([]); }); + + it('is sent again after a content script asked for the list to be cleared', async () => { + // Only the player's "Clear Sources" button clears it (MessageSenders). + await playerHandedAStream(); + await bg.message({type: 'CLEAR_SOURCES'}, {tabId: 1, frameId: 5, url: 'https://site.test/watch'}); + expect(await askAgain()).toEqual(['https://cdn.test/v/master.m3u8']); + }); }); describe('a page that is never left', () => { diff --git a/tests/unit/backgroundHarness.mjs b/tests/unit/backgroundHarness.mjs index 2e3194fd8..a51bfe3a4 100644 --- a/tests/unit/backgroundHarness.mjs +++ b/tests/unit/backgroundHarness.mjs @@ -307,8 +307,10 @@ export async function loadBackground({ const log = console.log; console.log = () => {}; + /** @type {Object} */ + let senders = {}; try { - await import('../../chrome/background/background.mjs'); + ({MessageSenders: senders} = await import('../../chrome/background/background.mjs')); await settle(); } finally { console.log = log; @@ -334,13 +336,16 @@ export async function loadBackground({ * @param {{tabId?: number, frameId?: number, url?: string, tab?: Object, * onAnswer?: function(*): void}} [from] - Its sender; onAnswer runs the moment the answer * comes, as the sender's callback does, before anything else the background has started. + * Without a url, one of FastStream's own messages comes from its player page. * @return {Promise<*>} The background's answer, once given (undefined for none). */ bg.message = async (message, {tabId = 1, frameId = 0, url, tab, onAnswer} = {}) => { const sender = { tab: tab || {id: tabId, url: (bg.tabs.find((t) => t.id === tabId) || {}).url}, frameId, - url, + // A message only FastStream's own pages send (MessageSenders) comes from the player, + // unless the test names another sender: a content script's is ignored. + url: url ?? (senders[message?.type] === 'own' ? 'moz-extension://bg-test/player/index.html' : undefined), }; let reply; const answered = new Promise((resolve) => { diff --git a/tests/unit/backgroundMessages.test.mjs b/tests/unit/backgroundMessages.test.mjs new file mode 100644 index 000000000..71a9eb18c --- /dev/null +++ b/tests/unit/backgroundMessages.test.mjs @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import {loadBackground} from './backgroundHarness.mjs'; + +// Who may send the background each message it answers (background.mjs's MessageSenders): +// FastStream's own pages only, or its content scripts too, which run in every page. Some +// handlers checked their sender and others did not: LOAD_OPTIONS, MPV_TEST, CLEAR_SOURCES and +// REQUEST_PLAYLIST_NAVIGATION, which only the options page or the player send, did what a +// content script asked (glm's review, 2026-10-28). One list, read before any handler, is the +// check now, and a new message cannot be answered without saying who may send it. + +const chromeDir = path.resolve(import.meta.dirname, '../../chrome'); +const PAGE = 'https://site.test/watch'; +const PLAYER = 'moz-extension://bg-test/player/index.html'; + +let bg; + +afterEach(() => { + bg?.unload(); + bg = null; + vi.restoreAllMocks(); +}); + +/** @return {Set} The types runtime.onMessage's listener answers: its branches. */ +function answeredTypes() { + const source = fs.readFileSync(path.join(chromeDir, 'background/background.mjs'), 'utf8'); + const start = source.indexOf('chrome.runtime.onMessage.addListener('); + const end = source.indexOf('\n});\n', start); + return new Set([...source.slice(start, end).matchAll(/msg\.type === MessageTypes\.([A-Z_]+)/g)].map((m) => m[1])); +} + +/** @return {Set} The types the content scripts send (a message's `type:` field). */ +function contentScriptTypes() { + const manifest = JSON.parse(fs.readFileSync(path.join(chromeDir, 'manifest.json'), 'utf8')); + const files = new Set(manifest.content_scripts.flatMap((entry) => entry.js)); + const types = new Set(); + for (const file of files) { + const source = fs.readFileSync(path.join(chromeDir, file), 'utf8'); + for (const m of source.matchAll(/type:\s*(?:MessageTypes\.([A-Z_]+)|['"]([A-Z_]+)['"])/g)) types.add(m[1] || m[2]); + } + return types; +} + +describe('Who may send the background a message', () => { + it('is said for every type the listener answers, and for no other', async () => { + bg = await loadBackground(); + const {MessageSenders} = await import('../../chrome/background/background.mjs'); + const answered = answeredTypes(); + // A pattern that stopped matching would make this pass on nothing. + expect(answered.size).toBeGreaterThanOrEqual(30); + expect([...answered].filter((type) => !(type in MessageSenders))).toEqual([]); + expect(Object.keys(MessageSenders).filter((type) => !answered.has(type))).toEqual([]); + expect(new Set(Object.values(MessageSenders))).toEqual(new Set(['own', 'any'])); + }); + + it('is FastStream\'s own pages only for no type a content script sends', async () => { + bg = await loadBackground(); + const {MessageSenders} = await import('../../chrome/background/background.mjs'); + const sent = contentScriptTypes(); + expect(sent.has('LOADED_MEDIA')).toBe(true); + expect(sent.has('DETECTED_SOURCE')).toBe(true); + expect([...sent].filter((type) => MessageSenders[type] === 'own')).toEqual([]); + }); + + it('ignores a content script that asks to read the options anew', async () => { + bg = await loadBackground({tabs: [{id: 1, url: PAGE}]}); + await bg.message({type: 'LOAD_OPTIONS', time: 7}, {tabId: 1, url: PAGE}); + await bg.wait(1000); + expect(bg.sent('UPDATE_OPTIONS')).toEqual([]); + + await bg.message({type: 'LOAD_OPTIONS', time: 8}, {tabId: 1, url: PLAYER}); + await bg.wait(1000); + expect(bg.sent('UPDATE_OPTIONS').map((m) => m.message.time)).toEqual([8]); + }); + + it('ignores a content script that asks for the mpv test, which tells the mpv path', async () => { + bg = await loadBackground({options: {mpvMode: true, mpvPath: 'C:/mpv/mpv.exe'}, + onNative: (m) => ({ok: true, mpv: true, path: m.mpvPath})}); + expect(await bg.message({type: 'MPV_TEST'}, {tabId: 1, url: PAGE})).toBe(undefined); + await bg.wait(500); + expect(bg.native).toEqual([]); + }); + + it('answers its own pages as before, and a content script\'s own messages too', async () => { + bg = await loadBackground(); + expect(await bg.message({type: 'PING'}, {tabId: 1, url: PLAYER})).toBe('PONG'); + expect(await bg.message({type: 'PING'}, {tabId: 1, url: PAGE})).toBe(undefined); + // LOADED_MEDIA is a content script's: answered from a page. + expect(await bg.message({type: 'LOADED_MEDIA', resources: []}, {tabId: 1, url: PAGE})).not.toBe(undefined); + }); +}); diff --git a/tests/unit/backgroundRobustness.test.mjs b/tests/unit/backgroundRobustness.test.mjs index 441db2f09..9386d3e58 100644 --- a/tests/unit/backgroundRobustness.test.mjs +++ b/tests/unit/backgroundRobustness.test.mjs @@ -60,7 +60,7 @@ describe('a download from a container tab', () => { expect(hidden.cookieStoreId).toBe('firefox-container-1'); for (const listener of bg.chrome.runtime.onMessage.listeners) { listener({type: 'PLAYER_LOADED', url: 'moz-extension://bg-test/player/index.html'}, - {tab: {id: hidden.id, url: hidden.url}, frameId: 0}, () => {}); + {tab: {id: hidden.id, url: hidden.url}, frameId: 0, url: 'moz-extension://bg-test/player/index.html'}, () => {}); } return answers; }