Playwright: Loading Chrome Extensions

First post? Every blog has to start somewhere – and this is a cool thing and I wanted to brag share information for the good of humanity. Or something.

Anyway, I had a test:
Test case: Ensure product detects Chrome extensions
Steps: 
Given: Chrome with extensions loaded
When: User logs in
Then: Error screen alerting user to disable the offending extensions

Extremely easy to do manually.

As Chrome and its extensions are continuously updated, and the list of extensions we need to check for is an ever changing target, this seems like an easy target for automation. 

Famous last words. 

Solution: We need 5 files for a 5-step solution

Environment: Github Actions, Linux container (specifically)

Managed Policies JSON

First, we’re going to hack into Chrome’s Managed Policies protocol. This is a special API for system administrators, allowing them to push policies to their end users. Among other things, it forces Chrome to install extensions. It does this by placing a JSON file in a specific folder. When the user opens Chrome, Chrome will automatically check for this JSON file and execute on it. 

The JSON file is below. Each extension on the Chrome Web Store has a unique ID – this code never changes and will always download the latest version. You can get the ID from the extension URL in the Chrome Web Store.

Note that if the extension is removed from the Chrome Web Store, nothing will happen when this JSON is executed. Every other extension in your JSON file will install just fine, and you’ll receive no error – something to keep in mind for debugging…

JSON File: 

{
  "ExtensionSettings": {
    "ddkjiahejlhfcafbddmgiahcphecmpfh": {
      "name": "UBlock Origin",
      "installation_mode": "force_installed",
      "update_url": "https://clients2.google.com/service/update2/crx"
    },
     "gmbmikajjgmnabiglmofipeabaddhgne": {
      "name": "Save to Drive",
      "installation_mode": "force_installed",
      "update_url": "https://clients2.google.com/service/update2/crx"
    }
  }
}

Shell Script

Next, we need a shell script to actually download the extensions. You’ll see why at the next step – but for now just know that while the JSON file has a list of extension IDs, in order to download the CRX files and then unpack them into file/folders, we need to actually launch Chrome. 

For security, Google has locked down access to the Chrome Web Store – the only reasonable way to download and unpack an extension is inside Chrome itself.  

Note: I am not a shell user. I came of age in the era of reliable GUIs. It took some time for me to patch this script together, and I’m still not completely certain I understand every command. 

The first thing we do is copy over the managed_policies.json into the appropriate folder, which Google has kindly provided in their documentation. 

Next, we launch Chrome. Since we’re doing this in CI (Github Actions), we need some special commands. 

xvfb-run: creates a virtual display xvfb-run to launch with a virtual monitor – or else Chrome will not initialize. 

-a: flag says to use whatever monitor number is available. Prevents conflicts with Playwright (which will also use xvfb with the -a flag). 

–no-sandbox:  is a special flag that disables security for Chrome. As we’re running this script as root, Chrome wont run without this flag. 

& sleep 60: When Chrome launches, the terminal will hang. Chrome has to be closed (by a user) for the script to continue. The & is a special character that effectively launches a new terminal and then continues the script in the new terminal. The problem is, we need to wait for Chrome to finish loading the extensions and also give it a chance to log any errors before we move forward. Hence, sleep 60 – pause the script for long enough for Chrome to finish opening, then we can kill it and move forward. 60 seconds is an arbitrary number that seems to work for my purposes. 

The last thing we do is just a basic check, print the contents of the folder where Chrome should have unpacked the extensions. Delicious debugging.

Footnote: I did come across other shell scripts on the internet that purported to install Chrome extensions. They failed in 2 ways: first, all they did was generate a JSON file (per above), but the format they used is not officially supported (though it does work… sometimes). Second, they fail to actually launch Chrome, relying on a live user to do so instead. 

Shell Script: 

#!/bin/bash

#Debug function, finds extensions if they were loaded somewhere else
# TODO change the extension ID to a valid one
find_folder () {
  echo "find extension:"
  find / -name '*kdfieneakcjfaiglcfcgkidlkmlijjnh*' -type d
  echo "find json:"
  find / -name '*managed_policies*' -type f
}

print_folder() {
 extensions_directory="/home/runner/.config/google-chrome/Default/Extensions/"
  for f in "$extensions_directory"/*; do
      basename "$f"
  done | awk 'END { printf("File count: %d", NR); } NF=NF'
}

configure_managed_policy () { 
  managed_folder_path="/etc/opt/chrome/policies/managed"
  # -p flag creates all parent folders as needed
  mkdir -p "$managed_folder_path"
  echo "$PWD"
  cp -v "./fixtures/managed_policies.json" "$managed_folder_path"
} 

echo "Create JSON"
configure_managed_policy


#run Chrome with xvfb for a virtual screen
#this should read the JSON files and load the extensions. Kill chrome to free resources
# -a automates the display ID so it doesnt conflict with Playwright later
#--no-sandbox is a Chrome flag to disable security when running as root
# & tells Bash to open a new command line. Otherwise the terminal will 'wait' for Chrome to close - which it wont ever do. 
# Instead, we open a new command line, wait 60 seconds for Chrome to (hopefully) finish loading extensions, then kill it
# NOTE: it is possible 60 seconds is not long enough. If the extensions are not loading, try increasing this number
echo "Open Chrome"
xvfb-run -a --server-args='-screen 0, 1024x768x16' google-chrome --no-sandbox & sleep 60
echo "Kill chrome"
pkill chrome

#after chrome is killed, the extension folders should be created and we should see all the extension folders
#Note if this is returning nothing, it may mean the extensions folder was not created in the right location
#  In that case, re-run 'find_folder' and a valid extension ID to find the extension folder
echo "Find extensions"
print_folder
#find_folder

Playwright Fixture

We have our extensions downloaded, time to load them in Playwright’s version of Chromium. Because Playwright does not use Chrome, and its version of Chromium does not support loading extensions in the normal sense, we had to do all the above to get to this point. 

Chromium has a developer-friendly flag –load-extension, which accepts a comma-separated list of absolute file paths to any extensions. This is why we had to go through the last two steps to download and unpack the extensions into folders.

So now we do some for-loops to compile that list of absolute paths. 

Note: Playwright’s official documents say that Chromium can run extensions in headless mode. This seems to be a lie – whenever I try, Chromium launches but no extensions are loaded. Maybe that’ll be fixed by the time you’re reading this. 

Fixture: 

import { test as base, chromium, type BrowserContext } from "@playwright/test";
import fs from "fs";
import path from "path";

export const test = base.extend<{
  context: BrowserContext;
  extensionId: string;
}>({
  context: async ({}, use) => {
    // Generate a list of extension paths from the extensions directory
    // Folders have a fixed format
    // e.g. /extensions/[extensionName]/[version]
    const extensionPaths: string[] = [];
    const extensionParentFolders = fs
      .readdirSync(
        path.normalize(
          "/home/runner/.config/google-chrome/Default/Extensions/"
        ),
        {
          withFileTypes: true,
        }
      )
      .filter((parent) => parent.isDirectory())
      .map((parent) => parent.name);

    console.log("ExtensionParentFolders: " + extensionParentFolders.join(","));

    extensionParentFolders.forEach((folder) => {
      const versionFolder = fs
        .readdirSync(
          path.join(
            "/home/runner/.config/google-chrome/Default/Extensions/" + folder
          ),
          {
            withFileTypes: true,
          }
        )
        .filter((version) => version.isDirectory())
        .map((version) => version.name);

      console.log("VersionFolder: " + versionFolder.join(","));

      if (versionFolder.length > 0) {
        extensionPaths.push(
          path.join(
            "/home/runner/.config/google-chrome/Default/Extensions/" +
              folder +
              "/" +
              versionFolder[0]
          )
        );
      }
    });
    console.log("Extensions to load: " + extensionPaths.join(","));

    // import the extensions into chromium
    // Chromium does not support loading extensions into headless environments
    // this does not impact CI but locally you'll notice a browser window open
    console.log("Setting context");

    const context = await chromium.launchPersistentContext("", {
      headless: false,
      channel: "chromium",
      args: [
        `--disable-extensions-except=${extensionPaths.join(",")}`,
        `--load-extension=${extensionPaths.join(",")}`,
        `--disable-component-extensions-with-background-pages`,
      ],
    });
    await use(context);
    await context.close();
  },
  extensionId: async ({ context }, use) => {
    // for manifest v3:
    let [background] = context.serviceWorkers();
    if (!background) background = await context.waitForEvent("serviceworker");

    const extensionId = background.url().split("/")[2];
    await use(extensionId);
  },
});
export const expect = test.expect;

Notice this is a fixture  – that means it overloads the default Chromium fixture. To use this, in the actual test file we simply import ‘test’ from this custom fixture instead of from ‘@Playwright’: 

import { test, expect } from "./fixtures/extension_fixture";

Github Workflow

The final piece – we just gotta run it! 

Order of these commands matters. The script takes advantage of Playwright automatically installing xvfb as a dependency, among other things. 

‘sudo’ is running every command as root admin – this is not ideal, but because the shell script has to be run as root admin, access to all the extensions files are locked to other users. Not using root also changes where the files are generated, and things just kinda get messy to be honest. 

And of course because we have to run Chromium headed, we need to use xvfb-run with the -a flag just as we did in the shell script. 

Workflow YML:

name: Extensions Tests
on:
  workflow_dispatch:
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - name: Install dependencies
        run: sudo npm ci
      - name: Install Playwright Browsers
        run: sudo npx playwright install --with-deps
      - name: Install chrome extensions
        run: sudo sh ./fixtures/install-extensions.sh
      - name: Run Playwright tests
        run: sudo xvfb-run -a npx playwright test --grep "@extensions"