microsoft / playwright

Playwright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.
https://playwright.dev
Apache License 2.0
65.22k stars 3.55k forks source link

[Feature] Support testing Chrome extensions that utilize the Chrome side panel #26693

Open srmagura opened 1 year ago

srmagura commented 1 year ago

Hi, my company's product is a Chrome extension that renders UI inside the Chrome side panel, using the chrome.sidePanel API. I'm attaching a screenshot of our Chrome extension's UI, in case you are not familiar with the Chrome side panel.

My feature request is: Can you make it so Playwright can open the Chrome side panel and interact with the webpage that is rendered inside it?

This would allow us (and other Chrome extension authors) to create fully end-to-end tests for our extensions using Playwright.

I have reviewed the Playwright docs and API, and could not find a way to open the Chrome side panel. It is not possible to open the side panel programmatically from the extension service worker, as far as I know. The user has to click the extension icon. (If the user already has the side panel open, e.g. to view their bookmarks, they can also get to our extension's page using the dropdown at the top of the side panel.)

Once the side panel is open, we would need a way to get access to its Page object from our Playwright test code.

Workaround for the time being

Our current plan for testing the application is to have Playwright interact directly with the extension service worker. We can use Playwright's ability to execute JavaScript in the service worker's context to mimic the communication the service worker normally receives from the side panel.

This will give us test coverage of our service worker and content scripts, which is much better than nothing. That said, ideally we would like a true end-to-end test that also covers the UI we render in the side panel.


Thank you for this awesome library!!!

Screenshot of our application in the Chrome side panel:

image

yury-s commented 1 year ago

As the side panel is not a part of the web content, playwright does not support it out of the box and we are not planning to work on it at the moment. Have a look at this page which explains current state of extension debugging in Playwright. You can also try to connect to the sidebar via raw CDP protocol.

mxschmitt commented 1 year ago

Could you try setting the PW_CHROMIUM_ATTACH_TO_OTHER=1 environment variable? This might make it work which helped Microsoft Edge folks doing similar things.

srmagura commented 1 year ago

Could you try setting the PW_CHROMIUM_ATTACH_TO_OTHER=1 environment variable? This might make it work which helped Microsoft Edge folks doing similar things.

Hi @mxschmitt, could you provide some additional context on your suggestion? For example, I am not sure what JavaScript code to write in my test to attempt to open the Chrome side panel.

mxschmitt commented 1 year ago

If you set this env var, it should/could be available via context.pages().

srmagura commented 1 year ago

If you set this env var, it should/could be available via context.pages().

That is a good tip, but I don't think it will help in this case. The webpage rendered in the side panel does not "exist" until the side panel is opened by clicking on the extension icon.

I am going to look into @yury-s's suggestion of using raw CDP (though I know nothing about that protocol, so I am not sure if it will provide a solution).

keenua commented 10 months ago

@srmagura, did you manage to find a way to test the sidepanel? Are there any tips you can share?

srmagura commented 10 months ago

Hi @keenua, I never got around to doing a deep dive into this issue, so I don't have any tips. Good luck finding a solution πŸ˜€

keenua commented 10 months ago

I figured out a way that works for me:

  1. In the test build of your extension, add a button to the content script, that would open the extension panel. You can remove/hide that button for the production build.
const button = new DOMParser().parseFromString(
    '<button id="openSidePanel">Click to open side panel</button>',
    'text/html'
).body.firstElementChild;
button.addEventListener('click', function () {
    chrome.runtime.sendMessage({ type: 'open_side_panel' });
});
document.body.append(button);
  1. In the background page of your extension (or service-worker.js), add a listener for the open_side_panel event.
chrome.runtime.onMessage.addListener((message, sender) => {
    // The callback for runtime.onMessage must return falsy if we're not sending a response
    (async () => {
      if (message.type === 'open_side_panel') {
        // This will open a tab-specific side panel only on the current tab.
        // @ts-ignore
        await chrome.sidePanel.open({ tabId: sender.tab.id });
        await chrome.sidePanel.setOptions({
          tabId: sender.tab.id,
          path: 'src/pages/sidepanel/sidepanel.html', // replace with your sidepanel index
          enabled: true
        });
      }
    })();
  });
  1. In your playwright project, create a fixture.ts file that loads an extension, as suggested in the docs. Also pass the PW_CHROMIUM_ATTACH_TO_OTHER environment variable.
import { test as base, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';

process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1";

export const test = base.extend<{
  context: BrowserContext;
  extensionId: string;
}>({
  context: async ({ }, use) => {
    const pathToExtension = path.join(__dirname, 'your-extension');
    const context = await chromium.launchPersistentContext('', {
      headless: false,
      args: [
        `--disable-extensions-except=${pathToExtension}`,
        `--load-extension=${pathToExtension}`,
      ],
    });
    await use(context);
    await context.close();
  },
  extensionId: async ({ context }, use) => {
    let [background] = context.serviceWorkers();
    console.log("Background: ", background?.url() ?? "None");
    if (!background)
      background = await context.waitForEvent('serviceworker');

    const extensionId = background.url().split('/')[2];
    await use(extensionId);
  },
});
export const expect = test.expect;
  1. In your test, make sure to click that newly created button before testing your extension. The extension page would be accessible from the page.context().pages().
test('has title', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  await page.locator("#openSidePanel").click(); // this would open the side panel with your extension's content

  const extension = page.context().pages()[3]; // accessing by index for simplicity, check the urls / extension id when actually using it
  await extension.getByText("Login").click(); // this would be performed inside of the extension
});

One could probably replace the button with something less invasive, but it should be a good starting point for anyone trying to test their sidepanels.

agallardol commented 7 months ago

@keenua Thanks for sharing your workaround, it seems to be working for us πŸš€.

TLDR:

  1. Add process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1"; to your project
  2. Make sure your side panel is opened (so the instance for that page exists)
  3. Get a reference to your extension app running in the side padel (You can use your extensionId and find it as a part of the url).
    // sidePanelPage is of Page type (same you commonly use when works with Playwright)
    const sidePanelPage: Page = page.context().pages().find((value) => value.url().match(extensionId));
  4. Now you can execute expressions like sidePanelPage.getByTestId('my-beauty-button').click()

It could be really useful if you implement this logic as a fixture so in that way you can get access to your sidePanelPage on any test you need.

aendel commented 1 month ago

Hi everyone, thanks to @keenua and to @agallardol for their workaround!

I would like to share some findings that I had to overcome, it may help someone else during their journey while testing chrome extensions using these workarounds:

First of all, I'm running all my tests using:

pnpm exec playwright test --ui

process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1';

const pathToExtension = path.resolve(__dirname, '..', 'dist');

export const test = base.extend<{ context: BrowserContext; extensionId: string; }>({ // eslint-disable-next-line no-empty-pattern context: async ({}, use) => { const context = await chromium.launchPersistentContext('', { locale: 'en-US', headless: false, args: [ --disable-extensions-except=${pathToExtension}, --load-extension=${pathToExtension}, // ! Needed for normalize the size of the side panel '--start-maximized', ], }); await use(context); await context.close(); }, extensionId: async ({ context }, use) => { 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;


Plus the viewport which has to be set a null on the devices configs
```ts
// playwright.config.ts
...
projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Use prepared auth state.
        storageState: 'playwright/.auth/user.json',
        // ! Override setting from ...devices['Desktop Chrome'],
        // ! Needed for work in conjunction with normalization the size of the side panel
        deviceScaleFactor: undefined,
        viewport: null
      },
    },
...
 const sidePanelPage = page
    .context()
    .pages()
    // * Two pages with the url inside, only the last one will be the one on the side panel
    // * We want to find the last one, so we are reverting it
    .reverse()
    .find((value) => value.url().match(extensionId));

I would like to hear your thought about them, thanks a lot.