Chrome resource loading behaviour - google-chrome

Today I ran into rather a strange behaviour of Chrome. I was playing with PerformanceObserver and found out that when you add two stylesheets with the same URL to the DOM very quickly then chrome fires only one request which obviously makes sense as it saves network load.
const testCase = async () => {
let numberOfRecords = 0
const observer = new PerformanceObserver((entryList) => {
const performanceEntries = entryList.getEntries()
numberOfRecords += performanceEntries.length
})
observer.observe({ entryTypes: ['resource'] })
// Test: Only one performance record is created because links are added at the same time
// and chrome detects duplicate request
const linkElement1 = document.createElement('link')
linkElement1.rel = 'stylesheet'
linkElement1.href = 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css'
document.head.appendChild(linkElement1)
const linkElement2 = document.createElement('link')
linkElement2.rel = 'stylesheet'
linkElement2.href = 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css'
document.head.appendChild(linkElement2)
// wait a little bit so performance observer callback is called
await new Promise((resolve) => {
setTimeout(() => resolve(), 1000)
})
console.assert(numberOfRecords === 1, 'Test')
console.log('Test finished')
}
testCase()
When sleep time is added between adding link nodes to DOM then chrome fires two requests (the second one is taken from cache)
const testCase = async () => {
let numberOfRecords = 0
const observer = new PerformanceObserver((entryList) => {
const performanceEntries = entryList.getEntries()
numberOfRecords += performanceEntries.length
})
observer.observe({ entryTypes: ['resource'] })
// Test: Only one performance record is created because links are added at the same time
// and chrome detects duplicate request
const linkElement1 = document.createElement('link')
linkElement1.rel = 'stylesheet'
linkElement1.href = 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css'
document.head.appendChild(linkElement1)
// wait here so chrome triggers two requests
await new Promise((resolve) => {
setTimeout(() => resolve(), 1000)
})
const linkElement2 = document.createElement('link')
linkElement2.rel = 'stylesheet'
linkElement2.href = 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css'
document.head.appendChild(linkElement2)
// wait a little bit so performance observer callback is called
await new Promise((resolve) => {
setTimeout(() => resolve(), 1000)
})
console.assert(numberOfRecords === 2, 'Test')
console.log('Test finished')
}
testCase()
However when I run this second code via automated test (webdriver.io) or I try it on cloud service like Browserstack/Lambdatest (the same browser version, OS version) it fails as it triggers only one request. So I wonder what's the difference?
To see it by yourself you can open some empty page (it's quite important that page is empty and doesn't contain any background requests) and copy the code examples to console.

I just wonder whether you disabled the cache for the automation tests. In Chrome, apparently if you didn't tick "Disable cache", then you second test should fail.

Related

Puppeteer: how to access/intercept a FileSystemDirectoryHandle?

I'm wondering if it's possible within puppeteer to access a FileSystemDirectoryHandle (from the File System Access API). I would like to pass in a directory path via puppeteer as though the user had selected a directory via window.showDirectoryPicker(). On my client page I use the File System Access API to write a series of png files taken from a canvas element, like:
const directoryHandle = await window.showDirectoryPicker();
for (let frame = 0; frame < totalFrames; frame++){
const fileHandle = await directoryHandle.getFileHandle(`${frame}.png`, { create: true });
const writable = await fileHandle.createWritable();
updateCanvas(); // <--- update the contents of my canvas element
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
await writable.write(blob);
await writable.close();
}
On the puppeteer side, I want to mimic that behavior with something like:
const page = await browser.newPage();
await page.goto("localhost:3333/canvasRenderer.html");
// --- this part doesn't seem to exist ---
const [dirChooser] = await Promise.all([
page.waitForDirectoryChooser(),
page.click('#choose-directory'),
]);
await dirChooser.accept(['save/frames/here']);
//--------------------------------------
but waitForDirectoryChooser() doesn't exist.
I'd really appreciate any ideas or insights on how I might accomplish this!

create new tab in puppeteer inside a loop cause Navigation timeout

Recently I am learning puppeteer using their docs and try to scrape some information.
First approach
First I collect a list of url from the mainpage. Second I create a new tab and go those url iterately and collect some data. I doubt when I enter the loop the new tab didn't work as I expect and freezed without giving any data. Eventually I got a error TimeoutError: Navigation timeout of 30000 ms exceeded. Is there any better approach?
(async () => {
const browser = await puppeteer.launch({ headless: true });
const mainpage = await browser.newPage();
console.log('goto main page'.green);
await mainpage.goto(mainURL);
console.log('collecting some url'.green);
const URLS = await mainpage.evaluate(() =>
Array.from(
document.querySelectorAll('.result-actions a'),
(element) => element.href
)
);
if (typeof URLS[0] === 'string') console.log('OK'.green);
console.log('collecting finished'.green);
const newTab= await browser.newPage();
console.log('create new tab'.green);
var data = [];
for (let i = 0, n = URLS.length; i < n; i++) {
//console.log(URLS[i]);
// use this new tab to collect some data then close this tab
// continue this process
await newTab.waitForNavigation();
await newTab.goto(URLS[i]);
await newTab.waitForSelector('.profile-phone-column span a');
console.log('Go each url using new tab'.green);
// collecting data
data.push(collected_data);
// close this tab
await collectNamePage.close();
console.log(data);
}
await mainpage.close();
await browser.close();
console.log('closing browser'.green);
})();
Second approach
This time I want to skip the part where I collect those data using a new tab. Hence I collect my urls using page.$$() and try to iterating using for...of over urls and collect my data using elementHandle.$(selector) but this approach also failed.
I am getting frustrated. Am I doing it wrong way or I didn't understand their documentation?
In your script, you do not need newTab.waitForNavigation(); at all. Usually, this is used when the navigation is caused by some event. When you just use .goto(), the page loading is waited automatically.
Even if you need waitForNavigation(), you usually do not await it before the navigation triggered, otherwise you just get the timeout. You await it with navigation trigger together:
await Promise.all([element.click(), page.waitForNavigation()]);
So try to just delete await newTab.waitForNavigation();.
Also, do not close the new tab in the loop, delete it after the loop.
Edited script:
const puppeteer = require('puppeteer');
const mainURL = 'https://www.psychologytoday.com/us/therapists/illinois/';
(async () => {
const browser = await puppeteer.launch({ headless: false });
const mainpage = await browser.newPage();
console.log('goto main page');
await mainpage.goto(mainURL);
console.log('collecting urls');
const URLS = await mainpage.evaluate(() =>
Array.from(
document.querySelectorAll('.result-actions a'),
(element) => element.href
)
);
if (typeof URLS[0] === 'string') console.log('OK');
console.log('collection finished');
const collectNamePage = await browser.newPage();
console.log('create new tab');
var data = [];
for (let i = 0, totalUrls = URLS.length; i < totalUrls; i++) {
console.log(URLS[i]);
await collectNamePage.goto(URLS[i]);
await collectNamePage.waitForSelector('.profile-phone-column span a');
console.log('create new tab and go there');
// collecting data
const [name, phone] = await collectNamePage.evaluate(
() => [
document.querySelector('.profile-middle .name-title-column h1').innerText,
document.querySelector('.profile-phone-column span a').innerText
]
);
data.push({ name, phone });
}
console.log(data);
await collectNamePage.close();
await mainpage.close();
await browser.close();
console.log('closing browser');
})();

timer (for a countdown)

I created a countdown from 5 to 0. It start when you click on the "START" button:
<html>
<head>
<meta charset="utf-8"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.5.5/rxjs.umd.js">
</script>
</head>
<body>
<button id="start">START</button>
COUNTDOWN:<span id="countdown"></span>
<script>
let start = document.getElementById('start');
let start_click = rxjs.fromEvent(start, 'click');
start_click.subscribe(x => console.log('click'));
start_click.pipe(rxjs.operators.first()).subscribe(
()=> {
let time = rxjs.timer(0, 1000).pipe(
rxjs.operators.skip(0)
, rxjs.operators.take(6)
, rxjs.operators.map(x => 5-x)
);
time.subscribe(x => console.log('instant', x));
let countdown = document.getElementById('countdown');
time.subscribe(x => countdown.innerText = x);
start.disabled = true;
let end = time.pipe(
rxjs.operators.last()
, rxjs.operators.repeatWhen(() => start_click)
);
end.subscribe(x=>start.disabled = false);
start_click.subscribe(x => start.disabled = true);
});
</script>
</body>
</html>
I struggle to find how to reset the countdown when the "START" button is pressed again.
I tried to add:
start_click.subscribe(x => countdown.innerText = 5);
But the value is static. Thanks.
The reason it doesn't work after clicking 'Start' a second time is because you are using the first() operator on your start_click observable.
This means the observable only emits on the first click, then completes.
Simply remove .pipe(rxjs.operators.first()) and your code will work each time you click the button.
However, it's generally a good idea to avoid nested subscriptions when possible. This can help you avoid memory leaks (due not unsubscribing properly) and make the code easier to understand.
You can avoid using nested subscriptions by using one of the "Higher Order Mapping Operators" LINK. This is just a fancy way of saying: operators that map the incoming value to another observable, subscribe to it, and emit those values. They also manage these "inner subscriptions" automatically.
The switchMap operator will "switch" to a new observable whenever a new value is received. So in your case, whenever a new click is received, a new 5-second timer observable is created.
Simplified code could look something like this: Working StackBlitz
const start = document.getElementById('start');
const countdown = document.getElementById('countdown');
const start_click = rxjs.fromEvent(start, 'click');
const time = start_click.pipe(
tap(() => start.disabled = true),
switchMap(() => timer(0, 1000).pipe(
map(x => 5-x),
take(6),
finalize(() => start.disabled = false)
)),
);
time.subscribe(
x => countdown.innerText = x
);
Notice how there is only a single subscription now. We defined two different observables, start_click which is your stream of clicks and time which is your stream that emits the current value of the timer. time is defined from the start_click stream, so whenever a new click is received, under the hood a new timer gets created and emits values.
The issue is caused by rxjs.operators.first(),
try next
start_click.pipe(
rxjs.operators.tap(() => start.disabled = true)
, switchMap(() => rxjs.timer(0, 1000).pipe(
rxjs.operators.skip(0) // <- do you need it?
, rxjs.operators.take(6)
, rxjs.operators.map(x => 5-x)
, rxjs.operators.finalize(() => start.disabled = false)
)),
).subscribe(x => {
console.log('instant', x);
let countdown = document.getElementById('countdown');
countdown.innerText = x;
});

MediaRecorder switch video tracks

I am using MediaRecorder API to record videos in web applications. The application has the option to switch between the camera and screen. I am using Canvas to augment stream recording. The logic involves capturing stream from the camera and redirecting it to the video element. This video is then rendered on canvas and the stream from canvas is passed to MediaRecorder.
What I noticed is that switching from screen to video (and vice-versa) works fine as long as the user doesn't switch/minimize the chrome window. The canvas rendering uses requestAnimationFrame and it freezes after the tab loses its focus.
Is there any way to instruct chrome not to pause the execution of requestAnimationFrame? Is there any alternate way to switch streams without impacting MediaRecorder recording?
Update:
After reading through the documentation, tabs which play audio or having active websocket connection are not throttled. This is something which we are not doing at this moment. This might be a workaround, but hoping for any alternative solution from community. (setTimeout or setInterval are too throttled and hence not using that, plus it impacts rendering quality)
Update 2:
I could able to fix this problem using Worker. Instead of using Main UI Thread for requestAnimationFrame, the worker is invoking the API and the notification is sent to Main Thread via postMessage. Upon completion of rendering by UI Thread, a message is sent back to Worker. There is also a delta period calculation to throttle overwhelming messages from worker.
There is an ongoing proposal to add a .replaceTrack() method to the MediaRecorder API, but for the time being, the specs still read
If at any point, a track is added to or removed from stream’s track set, the UA MUST immediately stop gathering data, discard any data that it has gathered [...]
And that's what is implemented.
So we still have to rely on hacks to make this by ourselves...
The best one is probably to create a local RTC connection, and to record the receiving end.
// creates a mixable stream
async function mixableStream( initial_track ) {
const source_stream = new MediaStream( [] );
const pc1 = new RTCPeerConnection();
const pc2 = new RTCPeerConnection();
pc1.onicecandidate = (evt) => pc2.addIceCandidate( evt.candidate );
pc2.onicecandidate = (evt) => pc1.addIceCandidate( evt.candidate );
const wait_for_stream = waitForEvent( pc2, 'track')
.then( evt => new MediaStream( [ evt.track ] ) );
pc1.addTrack( initial_track, source_stream );
await waitForEvent( pc1, 'negotiationneeded' );
try {
await pc1.setLocalDescription( await pc1.createOffer() );
await pc2.setRemoteDescription( pc1.localDescription );
await pc2.setLocalDescription( await pc2.createAnswer() );
await pc1.setRemoteDescription( pc2.localDescription );
} catch ( err ) {
console.error( err );
}
return {
stream: await wait_for_stream,
async replaceTrack( new_track ) {
const sender = pc1.getSenders().find( ( { track } ) => track.kind == new_track.kind );
return sender && sender.replaceTrack( new_track ) ||
Promise.reject( "no such track" );
}
}
}
{ // remap unstable FF version
const proto = HTMLMediaElement.prototype;
if( !proto.captureStream ) { proto.captureStream = proto.mozCaptureStream; }
}
waitForEvent( document.getElementById( 'starter' ), 'click' )
.then( (evt) => evt.target.parentNode.remove() )
.then( (async() => {
const urls = [
"2/22/Volcano_Lava_Sample.webm",
"/a/a4/BBH_gravitational_lensing_of_gw150914.webm"
].map( (suffix) => "https://upload.wikimedia.org/wikipedia/commons/" + suffix );
const switcher_btn = document.getElementById( 'switcher' );
const stop_btn = document.getElementById( 'stopper' );
const video_out = document.getElementById( 'out' );
let current = 0;
// see below for 'recordVid'
const video_tracks = await Promise.all( urls.map( (url, index) => getVideoTracks( url ) ) );
const mixable_stream = await mixableStream( video_tracks[ current ].track );
switcher_btn.onclick = async (evt) => {
current = +!current;
await mixable_stream.replaceTrack( video_tracks[ current ].track );
};
// final recording part below
// only for demo, so we can see what happens now
video_out.srcObject = mixable_stream.stream;
const rec = new MediaRecorder( mixable_stream.stream );
const chunks = [];
rec.ondataavailable = (evt) => chunks.push( evt.data );
rec.onerror = console.log;
rec.onstop = (evt) => {
const final_file = new Blob( chunks );
video_tracks.forEach( (track) => track.stop() );
// only for demo, since we did set its srcObject
video_out.srcObject = null;
video_out.src = URL.createObjectURL( final_file );
switcher_btn.remove();
stop_btn.remove();
const anchor = document.createElement( 'a' );
anchor.download = 'file.webm';
anchor.textContent = 'download';
anchor.href = video_out.src;
document.body.prepend( anchor );
};
stop_btn.onclick = (evt) => rec.stop();
rec.start();
}))
.catch( console.error )
// some helpers below
// returns a video loaded to given url
function makeVid( url ) {
const vid = document.createElement('video');
vid.crossOrigin = true;
vid.loop = true;
vid.muted = true;
vid.src = url;
return vid.play()
.then( (_) => vid );
}
/* Records videos from given url
** #method stop() ::pauses the linked <video>
** #property track ::the video track
*/
async function getVideoTracks( url ) {
const player = await makeVid( url );
const track = player.captureStream().getVideoTracks()[ 0 ];
return {
track,
stop() { player.pause(); }
};
}
// Promisifies EventTarget.addEventListener
function waitForEvent( target, type ) {
return new Promise( (res) => target.addEventListener( type, res, { once: true } ) );
}
video { max-height: 100vh; max-width: 100vw; vertical-align: top; }
.overlay {
background: #ded;
position: fixed;
z-index: 999;
height: 100vh;
width: 100vw;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
<div class="overlay">
<button id="starter">start demo</button>
</div>
<button id="switcher">switch source</button>
<button id="stopper">stop recording</button>
<video id="out" muted controls autoplay></video>
Otherwise you can still go the canvas way, with the Web Audio Timer I made for when the page is blurred, even though this will not work in Firefox since they do internally hook to rAF to push new frames in the recorder...
I had the same problem and trying to figure it out without too much complexity such as Canvas or SourceBuffer.
I used the PeerConnection for same page to make a connection. Once the connection is made you can use a rtpSender via peerconnection.addTrack And from there you can easily switch.
I just made a library and a demo that you can find:
https://github.com/meething/StreamSwitcher/

Puppeteer - How to interact with new window or force new window into same instance

I am using Puppeteer for testing in non-headless mode with Chromium. I have a part of a test where it clicks on a node on a webpage and that opens a new window. I am unable to interact with that new window. I have tried browser.pages(), unfortunately it only yields interaction with the pre-existing tabs in the original Chromium instance, not the new window.
example:
let pages = await browser.pages();
let newWindow = pages[pages.length - 1];
// try to find element on newWindow - fails
// last element in pages is still window that click was made in to open new window
I have found event: 'targetcreated' in the Puppeteer API but have been unable to figure out how to use it to access a new window.
I have also tried using browser.targets() to get the new instance but have not been successful.
I would like to know either how to interact with a new window instance or how to force all new windows to open in the same instance so they can be interacted with via browser.pages().
I don't believe this is a duplicate of the linked question about a similar subject of detecting when a new tab is opened as I am not just trying to detect when a new tab is opened but also to access the new tab/window within the test that opened the new tab/window.
I was able to solve this with an event listener for targetCreated, which multiple users were able to help me setup. The additional part that wasn't immediately obvious that I had to figure out myself was how to use that event listener to actually access the new page in the test that created it. I did so using lodash with a global.pages variable that the event listener adds any new pages to, see code below:
package.json
{
"name": "workflow-tests",
"version": "1.0.0",
"description": "Workflow tests",
"main": "index.js",
"scripts": {
"test": "mocha test/bootstrap.js --recursive test"
},
"author": "",
"license": "ISC",
"devDependencies": {
"chai": "^4.1.0",
"mocha": "^3.4.2"
},
"dependencies": {
"puppeteer": "^1.0.0"
}
}
test/bootstrap.js:
const puppeteer = require('puppeteer');
const { expect } = require('chai');
const _ = require('lodash');
const globalVariables = _.pick(global, ['browser', 'expect', 'pages']);
// puppeteer options
const opts = {
headless: false,
slowMo: 100,
timeout: 10000
};
// expose variables
before (async function () {
global.expect = expect;
global.browser = await puppeteer.launch(opts);
global.pages = await global.browser.pages();
// console.log('global.pages.length', global.pages.length);
// Event listener for taargetCreated events (new pages/popups)
// adds the new page to the global.pages variable so it can be accessed immediately in the test that created it
global.browser.on('targetcreated', async () => {
// console.log('New Tab Created');
global.pages = await global.browser.pages();
// console.log('global.pages.length', global.pages.length);
});
});
// close browser and reset global variables
after (function () {
browser.close();
global.browser = globalVariables.browser;
global.expect = globalVariables.expect;
global.pages = globalVariables.pages;
});
test/workflow1.js - pseudo code/example test that can access popup that is created:
describe('Workflow tests', function () {
let page = null;
this.timeout(60000);
it('should access new window after clicking opens it', () => {
return new Promise(async (resolve, reject) => {
page = global.pages[0];
await page.setViewport({ width: 1500, height: 1000 });
await page.goto('https://system.netsuite.com/pages/customerlogin.jsp');
// click something that opens a new window or use this test/example new window opener
window.open('http://www.example.com', '_blank');
// targetCreated event listener in test.bootstrap.js activated and sets global.pages to all open windows in instance
// new page/popup is last item in global.pages array
let popup = global.pages[global.pages.length - 1];
// do things in the new window - #addr2 is an example selector from my instance
await popup.waitForSelector('#addr2');
await popup.click('#addr2');
await popup.keyboard.type("popup test typing! :D");
await popup.keyboard.press('Enter');
resolve();
})
})
});
These tests would run with the command npm test. As these tests use async they require a certain version of node, I believe it is any node >= 8.9.1. I tried to run these tests on an earlier version of node and they did not work.