extend-chrome / jest-chrome

A complete mock of the Chrome API for Chrome extensions for use with Jest.
MIT License
124 stars 24 forks source link

Support for `chrome.action` #11

Open Mrtenz opened 3 years ago

Mrtenz commented 3 years ago

Is your feature request related to a problem? Please describe.

When using Manifest V3, chrome.browserAction (and pageAction) is replaced with chrome.action. It looks like this is not supported by jest-chrome currently.

Describe the solution you'd like

Support for chrome.action as alternative to chrome.browserAction.

jacksteamdev commented 3 years ago

I'm in the process of updating my projects to MV3, I'll put this on the todo list!

Thanks for mentioning it, @Mrtenz :1st_place_medal:

eegli commented 3 years ago

Hey @jacksteamdev - first of all, thank you for this awesome project. I was about to ask if you're planning on adding Manifest V3 support.

For those who are stuck right now and would like to mock chrome.action (or other V3 APIs), I created a little helper to mock those. All it needs is the types from @types/chrome.

Here is the helper:

// ./test-helper.ts

type Join<K, P> = K extends string | number
  ? P extends string | number
    ? `${K}${'' extends P ? '' : '.'}${P}`
    : never
  : never;

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];

type Paths<T, D extends number = 10> = [D] extends [never]
  ? never
  : T extends object
  ? {
      [K in keyof T]-?: K extends string | number
        ? `${K}` | Join<K, Paths<T[K], Prev[D]>>
        : never;
    }[keyof T]
  : '';

/* Utility function to mock currently unavailable methods in
'jest-chrome */

/**
 * Takes a path to a method of the Chrome API. Properties are accessed
 * via dot notation. Example:
 * ```
 * const scriptMock = mockForV3('scripting.executeScript')
 * ```
 * This will produce
 * ```
 * global.chrome.scripting.executeScript = jest.fn()
 * ```
 * The returned mock function above will mock
 * `scripting.executeScript`. Each returned mock function has all the
 * Jest methods available and you can add your custom implementations
 * as usual.
 * ```
 * scriptMock.mockImplementation(() => true)
 * ```
 * @param args string
 * @returns jest.Mock - Generic jest mock function
 *
 */
export default function <T extends Paths<typeof chrome>>(path: T) {
  const mockFn = jest.fn();
  const keys = path.split('.');

  function deepRecreate(): void {
    const methods = keys.reduceRight((obj, next, idx) => {
      if (idx === keys.length - 1) {
        return { [next]: mockFn };
      }
      return { [next]: obj };
    }, {});

    Object.assign(global.chrome, methods);
  }

  deepRecreate();
  return mockFn;
}

If you want to go the easy way, just put it somewhere and import the module when necessary. The function will recreate the global chrome object according to the path you provide as a string. With Typescript, you even get IntelliSense and linting.

The function will return a new mock function with all the Jest methods available, and you can add your custom mock implementation.

If you want to have type-safe mocks, just cast the returned mock to be whatever you want (as in the official docs). Otherwise, just use the returned mock, but be aware that, depending on what you test, your tests may fail because you're not providing the correct implementations.

Here is an example with a "loosely" typed mock (a mock implementation that does not conform to the actual API and a "strongly" typed mock:

// ./test/example.spec.ts

import mockForV3 from '../test-helper';

// Mocking a V3 API
async function getTabGroup() {
  const { color } = await chrome.tabGroups.get(1);
  if (!color) throw new Error('No color!');
  return color;
}

describe('Sample test', () => {
  it('fails with loose implementation', async () => {
    // Example with "loose implementation"
    const looseScriptMock = mockForV3('tabGroups.get');

    // Custom response, does not have to follow API interface
    looseScriptMock.mockImplementation(async () => ({
      collapsed: true,
      // color: 'blue',                           // Left out!
      id: 1,
      windowId: 1
    }));

    await expect(getTabGroup()).rejects.toThrow();
  });

  it('works with strong implementation', async () => {
    // Example with mocking the actual implementation
    const strongScriptMock = mockForV3('tabGroups.get') as jest.MockedFunction<
      typeof chrome.tabGroups.get
    >;

    // TS complains if something is removed from the mocked response
    strongScriptMock.mockImplementation(async () => ({
      collapsed: true,
      color: 'blue',
      id: 1,
      windowId: 1
    }));

    await expect(getTabGroup()).resolves.toBe('blue');
  });
});

Bonus

If you want to have the helper available globally (just like describe, etc.), add this in jest.setup.ts:

// ./jest.setup.ts

// Assuming the helper file is in the root dir
global.mockForV3 = require('./test-helper').default;

// From the package
Object.assign(global, require('jest-chrome'));

Create a global.d.ts file (or any other name) and add this:

// ./typings/global.d.ts

declare var mockForV3: typeof import('../test-helper').default;

Note that in this case, the global.d.ts file is located in ./typings/.

And, last but not least, make sure TS is aware of your definitions file.

{
  "compilerOptions": {
    ...
  "include": ["src/**/*", "test/**/*", "typings/**/*", "jest.setup.ts"],
}

Now mockForV3 is available globally and you don't need to import it.

To recap: This is my directory structure:

test/
├─ example.spec.ts
typings/
├─ globals.d.ts
tsconfig.json
jest.setup.ts
test-helper.ts

Maybe this is helpful to someone. Thank you for your work!

jacksteamdev commented 3 years ago

@eegli That's some real TypeScript magic! Love it! Thank you so much.

prli commented 2 years ago

I ended up mocking action with everything else still using jest-chrome

Object.assign(global, require('jest-chrome'));
global.chrome.action = {
    setTitle: jest.fn()
}