playwright icon indicating copy to clipboard operation
playwright copied to clipboard

[feature] clipboard isolation

Open Meemaw opened this issue 3 years ago • 5 comments

Hey.

We're facing some issues with copy to clipboard functionality in Playwright:

page.evaluate(() => navigator.clipboard.readText())

In some cases this returns a completely random value, and not one that we would expect.

In our example we are getting an Ethereum address from Metamask by clicking on an button which copies it to clipboard. I'm attaching some screenshots from the trace viewer that demonstrate the problem:

Screenshot 2022-03-26 at 20 13 02 Screenshot 2022-03-26 at 20 12 07 Screenshot 2022-03-26 at 20 12 02

We can see on the last screenshot that the value is completely different than what we would expect by the screenshoot 1 and 2. Because it is still an Ethereum address, and because we run tests with high worker count (6), it could potentially be leaking from another test (we do this same thing in a lot of tests).

This does not happen just with MetaMask (but with some other wallets as well), which makes me doubt bug is on their side.

Meemaw avatar Mar 26 '22 19:03 Meemaw

There is no clipboard isolation between different tests as of today. https://github.com/microsoft/playwright/issues/8114 is the master bug for tracking clipboard support.

yury-s avatar Mar 28 '22 16:03 yury-s

Per-above, closing to keep things consolidated to https://github.com/microsoft/playwright/issues/8114.

rwoll avatar Mar 28 '22 19:03 rwoll

Reopening this to track the clipboard isolation work.

aslushnikov avatar Jul 21 '22 21:07 aslushnikov

Example requests of clipboard isolation: https://github.com/microsoft/playwright/issues/8114#issuecomment-1103317576

aslushnikov avatar Jul 21 '22 21:07 aslushnikov

Assuming that we can trust browser APIs, this can be resolved by logging the write call:

async function spyClipboard(page: Page) {
  const log: string[] = []

  await page.exposeFunction('logValue', (value: string) => log.push(value))
  await page.addInitScript(() => {
    const originalImplementation = window.navigator.clipboard.writeText
    window.navigator.clipboard.writeText = async (...args) => {
      // @ts-expect-error Injected function
      logValue(args[0])
      await originalImplementation(...args)
    }
  })

  return log
}

// In your test
const clipboardCalls = await spyClipboard(page)
// ...
await expect(clipboardCalls[0]).toEqual('foo')

See also: https://playwright.dev/docs/mock#verifying-api-calls

Although I wonder... even though each test has their own context - is the window object still shared between contexts?

s-h-a-d-o-w avatar Aug 05 '22 15:08 s-h-a-d-o-w

This could be super useful to enable parallelism for tests using clipboard API. In our case, only a subset of test cases use the clipboard API so we end up introducing a file lock as a workaround:

export const test = base.extend<{
  clipboard: [
    async ({ page, browserName }, use) => {
      const locker = new Locker('clipboard');
      await locker.lock();
      await use(
        new Clipboard(page),
      );
      await locker.release();
    },
    { timeout: 30000 },
  ],
});

luin avatar Feb 01 '23 10:02 luin

Hey @luin thanks for sharing, I ran into similar issue.

It would be great if you can share complete solution? as the current one seems missing some part of the code.

Thanks!

phungleson avatar Feb 26 '23 04:02 phungleson

@phungleson I can't share the code as I wrote it for my company. But Locker is a tiny class where Locker#lock() basically tries to write and lock a local file with https://nodejs.org/api/fs.html#filehandlewritefiledata-options and will wait and retry if the file is already occupied by others.

luin avatar Feb 26 '23 04:02 luin

Ah cool thanks, yeah I managed to do something similar using cross-process-lock

// lockClipboard.ts
import { lock, UnlockFunction } from 'cross-process-lock';
import { writeFileSync, existsSync } from 'fs';

const name = 'tmp/clipboard';

// It should lock clipboard tests so that we can run one test at a time.
export const lockClipboard = async (): Promise<UnlockFunction> => {
  if (!existsSync(name)) {
    writeFileSync(name, '', 'utf8');
  }
  return await lock(name, { waitTimeout: 30000 });
};

Then in test.

let unlock: UnlockFunction;
test.beforeEach(async () => { unlock = await lockClipboard() });
test.afterEach(async () => await unlock());

It is not very elegant, hope clipboard isolation will come soon.

phungleson avatar Feb 26 '23 05:02 phungleson