In this javascript code which calls the immersive reader SDK, the onExit fires as expected, but the onPreferencesChanged never fires. What am I missing here?
async function LaunchImmersiveReader(title) {
const data = {
title: title,
chunks: [{
content: title,
lang: 'en'
}]
};
const token = await getImmersiveReaderTokenAsync();
const subdomain = await getImmersiveReaderSubdomainAsync();
const options = {
onPreferencesChanged: onPreferencesChangedCallback,
onExit: exitCallback
};
ImmersiveReader.launchAsync(token, subdomain, data, options);
}
function exitCallback() {
// this fires as I click the immersive reader exit/back button
}
function onPreferencesChangedCallback(value) {
// this never fires as I change font, size, etc.
}
What version of the SDK are you using? The preferences callback is part of 1.1.0
I did notice that a lot of our sample projects and documentation is using 1.0.0 still, which might be leading to the confusion
Related
I have a logging mechanism in place that saves the logs into an array. And I need a way to download the logs into a file.
I had this previously working (on manifest v2) with
const url = URL.createObjectURL(new Blob(reallyLongString, { type: 'text/plain' }));
const filename = 'logs.txt';
chrome.downloads.download({url, filename});
Now I am migrating to manifest v3 and since manifest v3 does not have URL.createObjectURL, you cannot create a url to pass to chrome.downloads.download
Instead it is possible to create a Blob URL using something like
const url = `data:text/plain,${reallyLongString}`;
const filename = 'logs.txt';
chrome.downloads.download({url, filename});
The problem is that chrome.downloads.download seems to have a limit on the number of characters passed in the url argument, and the downloaded file only contains a small part of the string.
So what would be a way to overcome this limitation?
Hopefully, a way to download Blob directly in service worker will be implemented in https://crbug.com/1224027.
Workaround via an extension page
Here's the algorithm:
Use an already opened page such as popup or options
Otherwise, inject an iframe into any page that we have access to
Otherwise, open a new minimized window
async function downloadBlob(blob, name, destroyBlob = true) {
// When `destroyBlob` parameter is true, the blob is transferred instantly,
// but it's unusable in SW afterwards, which is fine as we made it only to download
const send = async (dst, close) => {
dst.postMessage({blob, name, close}, destroyBlob ? [await blob.arrayBuffer()] : []);
};
// try an existing page/frame
const [client] = await self.clients.matchAll({type: 'window'});
if (client) return send(client);
const WAR = chrome.runtime.getManifest().web_accessible_resources;
const tab = WAR?.some(r => r.resources?.includes('downloader.html'))
&& (await chrome.tabs.query({url: '*://*/*'})).find(t => t.url);
if (tab) {
chrome.scripting.executeScript({
target: {tabId: tab.id},
func: () => {
const iframe = document.createElement('iframe');
iframe.src = chrome.runtime.getURL('downloader.html');
iframe.style.cssText = 'display:none!important';
document.body.appendChild(iframe);
}
});
} else {
chrome.windows.create({url: 'downloader.html', state: 'minimized'});
}
self.addEventListener('message', function onMsg(e) {
if (e.data === 'sendBlob') {
self.removeEventListener('message', onMsg);
send(e.source, !tab);
}
});
}
downloader.html:
<script src=downloader.js></script>
downloader.js, popup.js, options.js, and other scripts for extension pages (not content scripts):
navigator.serviceWorker.ready.then(swr => swr.active.postMessage('sendBlob'));
navigator.serviceWorker.onmessage = async e => {
if (e.data.blob) {
await chrome.downloads.download({
url: URL.createObjectURL(e.data.blob),
filename: e.data.name,
});
}
if (e.data.close) {
window.close();
}
}
manifest.json:
"web_accessible_resources": [{
"matches": ["<all_urls>"],
"resources": ["downloader.html"],
"use_dynamic_url": true
}]
Warning! Since "use_dynamic_url": true is not yet implemented don't add web_accessible_resources if you don't want to make your extension detectable by web pages.
Workaround via Offscreen document
Soon there'll be another workaround: chrome.offscreen.createDocument instead of chrome.windows.create to start an invisible DOM page where we can call URL.createObjectURL, pass the result back to SW that will use it for chrome.downloads.download.
I have a logging mechanism in place that saves the logs into an array. And I need a way to download the logs into a file.
I had this previously working (on manifest v2) with
const url = URL.createObjectURL(new Blob(reallyLongString, { type: 'text/plain' }));
const filename = 'logs.txt';
chrome.downloads.download({url, filename});
Now I am migrating to manifest v3 and since manifest v3 does not have URL.createObjectURL, you cannot create a url to pass to chrome.downloads.download
Instead it is possible to create a Blob URL using something like
const url = `data:text/plain,${reallyLongString}`;
const filename = 'logs.txt';
chrome.downloads.download({url, filename});
The problem is that chrome.downloads.download seems to have a limit on the number of characters passed in the url argument, and the downloaded file only contains a small part of the string.
So what would be a way to overcome this limitation?
Hopefully, a way to download Blob directly in service worker will be implemented in https://crbug.com/1224027.
Workaround via an extension page
Here's the algorithm:
Use an already opened page such as popup or options
Otherwise, inject an iframe into any page that we have access to
Otherwise, open a new minimized window
async function downloadBlob(blob, name, destroyBlob = true) {
// When `destroyBlob` parameter is true, the blob is transferred instantly,
// but it's unusable in SW afterwards, which is fine as we made it only to download
const send = async (dst, close) => {
dst.postMessage({blob, name, close}, destroyBlob ? [await blob.arrayBuffer()] : []);
};
// try an existing page/frame
const [client] = await self.clients.matchAll({type: 'window'});
if (client) return send(client);
const WAR = chrome.runtime.getManifest().web_accessible_resources;
const tab = WAR?.some(r => r.resources?.includes('downloader.html'))
&& (await chrome.tabs.query({url: '*://*/*'})).find(t => t.url);
if (tab) {
chrome.scripting.executeScript({
target: {tabId: tab.id},
func: () => {
const iframe = document.createElement('iframe');
iframe.src = chrome.runtime.getURL('downloader.html');
iframe.style.cssText = 'display:none!important';
document.body.appendChild(iframe);
}
});
} else {
chrome.windows.create({url: 'downloader.html', state: 'minimized'});
}
self.addEventListener('message', function onMsg(e) {
if (e.data === 'sendBlob') {
self.removeEventListener('message', onMsg);
send(e.source, !tab);
}
});
}
downloader.html:
<script src=downloader.js></script>
downloader.js, popup.js, options.js, and other scripts for extension pages (not content scripts):
navigator.serviceWorker.ready.then(swr => swr.active.postMessage('sendBlob'));
navigator.serviceWorker.onmessage = async e => {
if (e.data.blob) {
await chrome.downloads.download({
url: URL.createObjectURL(e.data.blob),
filename: e.data.name,
});
}
if (e.data.close) {
window.close();
}
}
manifest.json:
"web_accessible_resources": [{
"matches": ["<all_urls>"],
"resources": ["downloader.html"],
"use_dynamic_url": true
}]
Warning! Since "use_dynamic_url": true is not yet implemented don't add web_accessible_resources if you don't want to make your extension detectable by web pages.
Workaround via Offscreen document
Soon there'll be another workaround: chrome.offscreen.createDocument instead of chrome.windows.create to start an invisible DOM page where we can call URL.createObjectURL, pass the result back to SW that will use it for chrome.downloads.download.
I try to inject position changes to an JS/REACT-Application. The Application registering at window.navigator.geolocation.watchPosition. My idea is to stub the "watchPosition" method to get a handle on the callback function. Then calling the callback function from the application directly.
Like:
const watchPositionFake = (successCallback, errorCallback, options) => {
console.debug("PROXY set callback watchPosition");
originalWatchPositionSuccessCallback = successCallback;
};
cy.visit("/", {
onBeforeLoad(win) {
cy.stub(win.navigator.geolocation, "watchPosition").callsFake(watchPositionFake);
}
});
This doesn't work with function registering in the Application on the watchPosition. But this does work with function in the cypress-step file. (Working as in in the console.log I see changes in position according to the values I send in via originalWatchPositionSuccessCallback ).
Any idea who to fake a position change?
There is a different way to solve the issue of getting the callbacks of the registered function to navigator.geolocation.watchPosition triggered. The code in the question tried to solve this by cy.stub(win.navigator.geolocation, "watchPosition"), but this turned to be not working reliably (too soon, too late, different browser/window context, another iframe, ...), the precise reason varied.
An alternative solution to trigger the registered watchPosition callbacks without modifying the production code is the undocumented cypress (v6.2) automation interface in cypress to CDP.
export const setFakePosition = position => {
// https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setGeolocationOverride
console.debug(`cypress::setGeolocationOverride with position ${JSON.stringify(position)}`);
cy.log("**setGeolocationOverride**").then(() =>
Cypress.automation("remote:debugger:protocol", {
command: "Emulation.setGeolocationOverride",
params: {
latitude: position.latitude,
longitude: position.longitude,
accuracy: 50
}
})
);
};
And verifying with:
let positionLogSpy;
When("vehicle is located in {string}", city => {
const position = cityLocationMap[city];
cy.window()
.then(win => {
const expectedLogMessage = `new position lat: ${position.latitude}, lng: ${position.longitude}`;
positionLogSpy = cy.spy(win.console, "log").withArgs(expectedLogMessage);
})
.then(() => {
setFakePosition(position);
});
});
Then("vehicle has moved to {string}", () => {
expect(positionLogSpy).to.be.called;
});
After putting off testing for a while now due to Cypress not allowing visiting chrome:// urls, I decided to finally understand how to unit/integration test my extension - TabMerger. This comes after the many times that I had to manually test the ever growing functionality and in some cases forgot to check a thing or two. Having automated testing will certainly speed up the process and help me be more at peace when adding new functionality.
To do this, I chose Jest since my extension was made with React (CRA). I also used React Testing Library (#testing-library/react) to render all React components for testing.
As I recently made TabMerger open source, the full testing script can be found here
Here is the test case that I want to focus on for this question:
import React from "react";
import { render, fireEvent } from "#testing-library/react";
import * as TabFunc from "../src/Tab/Tab_functions";
import Tab from "../src/Tab/Tab";
var init_groups = {
"group-0": {
color: "#d6ffe0",
created: "11/12/2020 # 22:13:24",
tabs: [
{
title:
"Stack Overflow - Where Developers Learn, Share, & Build Careersaaaaaaaaaaaaaaaaaaaaaa",
url: "https://stackoverflow.com/",
},
{
title: "lichess.org • Free Online Chess",
url: "https://lichess.org/",
},
{
title: "Chess.com - Play Chess Online - Free Games",
url: "https://www.chess.com/",
},
],
title: "Chess",
},
"group-1": {
color: "#c7eeff",
created: "11/12/2020 # 22:15:11",
tabs: [
{
title: "Twitch",
url: "https://www.twitch.tv/",
},
{
title: "reddit: the front page of the internet",
url: "https://www.reddit.com/",
},
],
title: "Social",
},
};
describe("removeTab", () => {
it("correctly adjusts groups and counts when a tab is removed", () => {
var tabs = init_groups["group-0"].tabs;
const { container } = render(<Tab init_tabs={tabs} />);
expect(container.getElementsByClassName("draggable").length).toEqual(3);
var removeTabSpy = jest.spyOn(TabFunc, "removeTab");
fireEvent.click(container.querySelector(".close-tab"));
expect(removeTabSpy).toHaveBeenCalledTimes(1);
expect(container.getElementsByClassName("draggable").length).toEqual(2); // fails (does not remove the tab for some reason)
});
});
I mocked the Chrome API according to my needs, but feel that something is missing. To mock the Chrome API I followed this post (along with many others, even for other test runners like Jasmine): testing chrome.storage.local.set with jest.
Even though the Chrome storage API is mocked, I think the issue lies in this function which gets called upon initial render. That is, I think the chrome.storage.local.get is not actually being executed, but am not sure why.
// ./src/Tab/Tab_functions.js
/**
* Sets the initial tabs based on Chrome's local storage upon initial render.
* If Chrome's local storage is empty, this is set to an empty array.
* #param {function} setTabs For re-rendering the group's tabs
* #param {string} id Used to get the correct group tabs
*/
export function setInitTabs(setTabs, id) {
chrome.storage.local.get("groups", (local) => {
var groups = local.groups;
setTabs((groups && groups[id] && groups[id].tabs) || []);
});
}
The reason I think the mocked Chrome storage API is not working properly is because when I manually set it in my tests, the number of tabs does not increase from 0. Which forced me to pass a prop (props.init_tabs) to my Tab component for testing purposes (https://github.com/lbragile/TabMerger/blob/f78a2694786d11e8270454521f92e679d182b577/src/Tab/Tab.js#L33-L35) - something I want to avoid if possible via setting local storage.
Can someone point me in the right direction? I would like to avoid using libraries like jest-chrome since they abstract too much and make it harder for me to understand what is going on in my tests.
I think I have a solution for this now, so I will share with others.
I made proper mocks for my chrome storage API to use localStorage:
// __mocks__/chromeMock.js
...
storage: {
local: {
...,
get: function (key, cb) {
const item = JSON.parse(localStorage.getItem(key));
cb({ [key]: item });
},
...,
set: function (obj, cb) {
const key = Object.keys(obj)[0];
localStorage.setItem(key, JSON.stringify(obj[key]));
cb();
},
},
...
},
...
Also, to simulate the tab settings on initial render, I have a beforeEach hook which sets my localStorage using the above mock:
// __tests__/Tab.spec.js
var init_ls_entry, init_tabs, mockSet;
beforeEach(() => {
chrome.storage.local.set({ groups: init_groups }, () => {});
init_ls_entry = JSON.parse(localStorage.getItem("groups"));
init_tabs = init_ls_entry["group-0"].tabs;
mockSet = jest.fn(); // mock for setState hooks
});
AND most importantly, when I render(<Tab/>), I noticed that I wasn't supplying the id prop which caused nothing to render (in terms of tabs from localStorage), so now I have this:
// __tests__/Tab.spec.js
describe("removeTab", () => {
it("correctly adjusts storage when a tab is removed", async () => {
const { container } = render(
<Tab id="group-0" setTabTotal={mockSet} setGroups={mockSet} />
);
var removeTabSpy = jest.spyOn(TabFunc, "removeTab");
var chromeSetSpy = jest.spyOn(chrome.storage.local, "set");
fireEvent.click(container.querySelector(".close-tab"));
await waitFor(() => {
expect(chromeSetSpy).toHaveBeenCalled();
});
chrome.storage.local.get("groups", (local) => {
expect(init_tabs.length).toEqual(3);
expect(local.groups["group-0"].tabs.length).toEqual(2);
expect(removeTabSpy).toHaveBeenCalledTimes(1);
});
expect.assertions(4);
});
});
Which passes!!
Now on to drag and drop testing 😊
TL;DR A piece of Javascript code works flawlessly on Linux whilst behaving inconsistently on Windows.
I am coding an Electron app, using Vue.js for frontend, Vuex for data management and LokiJS for persistence storage (with its File System adapter at the background). I develop this application on Linux, but from time to time I have to switch to Windows to create a Windows build for the client. The Linux build always works flawlessly, the Windows one misbehaves. I assumed it was a LokiJS issue, however, upon the isolation of LokiJS-specific code, it worked properly even on Windows.
Here is simplified store.js file, which contains all relevant Vuex and LokiJS-related code in my application.
import loki from 'lokijs'
import LokiSFSAdapter from 'lokijs/src/loki-fs-structured-adapter'
import MainState from '../index' // a Vuex.Store object
const state = {
ads: [],
profiles: []
}
var sfsAdapter = new LokiSFSAdapter('loki')
var db = new loki('database.json', {
autoupdate: true,
autoload: true,
autoloadCallback: setupHandler,
adapter: sfsAdapter
})
function setupCollection (collectionName) {
var collection = db.getCollection(collectionName)
if (collection === null) {
collection = db.addCollection(collectionName)
}
}
function setupHandler () {
setupCollection('ads')
setupCollection('profiles')
MainState.commit('updateAds')
MainState.commit('updateProfiles')
}
window.onbeforeunload = function () {
db.saveDatabase()
db.close()
}
const mutations = {
updateAds (state) {
state.ads = db.getCollection('ads').data.slice()
},
updateProfiles (state) {
state.profiles = db.getCollection('profiles').data.slice()
}
}
const actions = {
async addProfile (context) {
db.getCollection('profiles').insert({ /* default data */ })
db.saveDatabase()
context.commit('updateProfiles')
},
async updateProfile (context, obj) {
db.getCollection('profiles').update(obj)
db.saveDatabase()
context.commit('updateProfiles')
},
async deleteProfile (context, id) {
db.getCollection('profiles').removeWhere({'$loki': {'$eq': id}})
db.saveDatabase()
context.commit('updateProfiles')
},
async addAd (context) {
db.getCollection('ads').insert({ /* default data */ })
db.saveDatabase()
context.commit('updateAds')
},
async deleteAd (context, id) {
db.getCollection('ads').removeWhere({'$loki': {'$eq': id}})
db.saveDatabase()
context.commit('updateAds')
}
}
Behaviour on Linux
it calls setupHandler every time the application starts,
it correctly saves data to database.json and the respective collections to database.json.0 and database.json.1 for ads and profiles
when addAd() is called, it can access all the data properly by calling db.getCollection('ads'), and then insert() on it.
Behaviour on Windows
only calls setupHandler if database.json doesn't exist. It correctly creates database.json if it doesn't exist, though.
creates only one file - database.json.0, but doesn't save any data there, it's just an empty file. It doesn't even create database.json.1 for the second collection.
obviously, since no data is actually saved, db.getCollection('ads') and returns null, which results into TypeError: Cannot read property 'insert' of null when calling addAd() on the successive application runs.
if this run database.json was created, the application behaves normally, insert() seems to work, however, no data is saved on exit and the successive runs result in the behaviour in the point above.
Question
Is this a bug somewhere deep in LokiJS/Vuex, or is it just me misusing their API?