Webusb

Testing WebUSB scenarios with Playwright

August 25, 2024 by Arjun Attam

Outline

  • WebUSB is an upcoming standard that bolsters the web platform - with crypto wallets, printers etc
  • Not covered in CDP - bunch of open bugs in Chromium
  • As a result it is not covered in Playwright or Puppeteer
  • The key issue is that WebUSB dialog to connect a USB device with the app (thrown by the navigator.usb.requestDevice API call) is not intercepted by the automation script
  • There’s a hack-y way to make it work via enterprise policies: which removes the dialog altogether
  • Steps
    • Create a policy on Chrome/Chromium
      • macOS steps
      • open chrome://policy to verify that the policy has been applied correctly
    • Create a fixture/setup project in your code to create the policy file and tear it down after the tests have finished running
      • Catch here: requires sudo
      • If you are testing a web page - then fixture is appropriate
      • If you are testing a chrome extension - maybe setup project is better

Show me the code

In your test fixtures file,

import { writePolicy } from "../utils/policy";
import { test as setup } from "../fixtures";
 
setup.skip(process.env.CI === "true");
 
setup("chrome policy setup for WebUSB", async ({ extensionId }) => {
  // Since WebUSB is not supported in CDP (and therefore Playwright), we need to use
  // a workaround that involves Chrome enterprise policy to allow specific WebUSB devices.
  // Chromium issue: https://issues.chromium.org/issues/40276988
  await writePolicy(extensionId);
});

Utiltiies for changing Chrome policy

import sudo from "sudo-prompt";
import { exec } from "child_process";
import fs from "fs";
 
function chromePolicyContent(extensionId: string): string {
  // Reference: https://chromeenterprise.google/policies/#WebUsbAllowDevicesForUrls
  // The vendor id is hard coded for now
  const vendorId = "0x2c97";
  return `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>WebUsbAllowDevicesForUrls</key>
	<array>
		<dict>
			<key>devices</key>
			<array>
				<dict>
					<key>vendor_id</key>
					<integer>${vendorId}</integer>
				</dict>
			</array>
			<key>urls</key>
			<array>
				<string>chrome-extension://${extensionId}</string>
			</array>
		</dict>
	</array>
</dict>
</plist>
  `;
}
 
function getSystemUsername() {
  return require("os").userInfo().username;
}
 
function getPolicyPath(withExtension: boolean) {
  // Reference: https://www.chromium.org/administrators/mac-quick-start/
  return `/Library/Managed Preferences/${getSystemUsername()}/org.chromium.Chromium${withExtension ? ".plist" : ""}`;
}
 
const SUDO_OPTIONS = {
  name: "Playwright tests",
};
 
export async function writePolicy(extensionId: string) {
  if (process.platform !== "darwin") {
    // TODO: remove this
    throw new Error(`Chrome policy is only supported on macOS`);
  }
  const tempFile = "temp.plist";
  const fileContent = chromePolicyContent(extensionId);
  fs.writeFileSync(tempFile, fileContent);
  const mvCommand = `mv ${tempFile} "${getPolicyPath(true)}"`;
  return new Promise((resolve, reject) => {
    sudo.exec(mvCommand, SUDO_OPTIONS, (error) => {
      if (error) {
        reject(error);
      }
      // macOS can cache the policy file, so we need to read it to force a reload
      exec(`defaults read "${getPolicyPath(false)}"`, (error) => {
        if (error) {
          reject(error);
        }
        resolve("done");
      });
    });
  });
}
 
export async function cleanupPolicy() {
  // TODO:
  const rmCommand = `rm -rf "${getPolicyPath(true)}"`;
  return new Promise((resolve, reject) => {
    sudo.exec(rmCommand, SUDO_OPTIONS, (error) => {
      if (error) {
        reject(error);
      }
      // macOS can cache the policy file, so we need to read it to force a reload
      exec(`defaults read "${getPolicyPath(false)}"`, () => {
        // This will error out because the file is removed, so ignore the error
        resolve("done");
      });
    });
  });
}