Test Web Bluetooth with Puppeteer

François Beaufort
François Beaufort

Web Bluetooth has been supported since Chrome 56, and lets developers write web apps that talk directly to users' Bluetooth devices. The Espruino web editor's ability to upload code to compatible Bluetooth devices is one such example. Testing these applications is now possible with Puppeteer.

This blog post walks through how to use Puppeteer to operate and test a Bluetooth-enabled web app. The key part of this is Puppeteer's ability to operate Chrome's Bluetooth device chooser.

If you aren't familiar with using Web Bluetooth in Chrome, the following video shows the Bluetooth prompt in Espruino web editor:

User selects a Puck.js bluetooth device in the Espruino web editor.

To follow this blog post, you'll need a Bluetooth-enabled web app, a Bluetooth device it can communicate with, and be using Puppeteer v21.4.0 or later.

Launch the browser

As with most Puppeteer scripts, start by launching the browser with Puppeteer.launch(). In order to access Bluetooth features, you need to provide a few extra arguments:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  headless: false,
  args: ["--enable-features=WebBluetooth"],
});

When opening the first page, it's generally recommended to use an incognito browser context. This helps prevent leaking permissions between the tests run with your script (though there is some OS level shared state that cannot be prevented by Puppeteer). The following code demonstrates this:

const browserContext = await browser.createIncognitoBrowserContext();
const page = await browserContext.newPage();

You can then navigate to the URL of the web app you are testing with Page.goto().

Open the Bluetooth device prompt

Once you've used Puppeteer to open the web app's page with Puppeteer, you can connect to the Bluetooth device to read data. This next step assumes you have a button on your web app that runs some JavaScript including a call to navigator.bluetooth.requestDevice().

Use Page.locator().click() to press that button, and Page.waitForDevicePrompt() to recognize when the Bluetooth device chooser appears. You must call waitForDevicePrompt() before clicking the button, otherwise, the prompt will have already opened up, and it won't be able to detect it.

Since both of these Puppeteer methods return promises, Promise.all() is a convenient way to call them in the right order together:

const [devicePrompt] = await Promise.all([
  page.waitForDevicePrompt(),
  page.locator("#start-test-button").click(),
]);

The promise returned by waitForDevicePrompt() resolves to a DeviceRequestPrompt object which you'll use next to select the Bluetooth device you want to connect to.

Select a device

Use DeviceRequestPrompt.waitForDevice() and DeviceRequestPrompt.select() to find and connect to the correct Bluetooth device.

DeviceRequestPrompt.waitForDevice() calls the supplied callback each time Chrome finds a Bluetooth device with some basic info about the device. The first time the callback returns true, waitForDevice() resolves to the matched DeviceRequestPromptDevice. Pass that device to DeviceRequestPrompt.select() to select and connect to that Bluetooth device.

const bluetoothDevice = await devicePrompt.waitForDevice(
  (d) => d.name == wantedDeviceName,
);
await devicePrompt.select(bluetoothDevice);

Once DeviceRequestPrompt.select() resolves, Chrome is connected to the device, and the web page is able to access it.

Read from the device

At this point, your web app will be connected to the chosen Bluetooth device and be able to read information from it. This might look like:

const serviceId = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";

const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: [serviceId] }],
});
const gattServer = await device.gatt.connect();
const service = await gattServer.getPrimaryService(serviceId);
const characteristic = await service.getCharacteristic(
  "0b30afd0-193e-11eb-adc1-0242ac120002",
);
const dataView = await characteristic.readValue();

For a more in-depth walkthrough of this sequence of API calls, see Communicating with Bluetooth devices over JavaScript.

At this point, you know how to use Puppeteer to automate the use of a Bluetooth-enabled web app by replacing the human step of selecting a device from the Bluetooth device chooser menu. While this might be generally useful, it is directly applicable to writing an end-to-end test for such a web app.

Create a test

The missing piece from taking the code so far to writing a full test is getting information out of the web app and into your Puppeteer script. Once you have this, it's fairly straightforward to use a testing library (like TAP or mocha) to verify the correct data was read and reported.

One of the easiest ways to do this is to write data to the DOM. JavaScript has plenty of ways to do this without additional libraries. Going back to your hypothetical web app, it might change the color of a status indicator when it reads data from the Bluetooth device or print the literal data in a field. For example:

const dataDisplayElement = document.querySelector('#data-display');
dataDisplayElement.innerText = dataView.getUint8();

From Puppeteer, Page.$eval() gives you a way to pull this data out of the page's DOM and into a test script. $eval() uses the same logic as document.querySelector() to find an element and then runs the supplied callback function with that element as the argument. Once you have this as a variable, use your assertion library to test if the data is what we expect.

const dataText = await page.$eval('#data-display', (el) => el.innerText);
equal(17, dataText);

Additional resources

To see more complex examples of writing tests for Bluetooth-enabled web apps with Puppeteer, see this repository: https://github.com/WebBluetoothCG/manual-tests/. The Web Bluetooth Community Group maintains this suite of tests, all of which can be run from a browser or locally. The "Read-only Characteristic" test is most similar to the example used in this blog post.

Acknowledgments

Thanks to Vincent Scheib for kicking off this project and providing valuable feedback on this post.