SurfSense/.cursor/skills/playwright-testing/multi-user-and-collaboration.md
2026-05-04 13:54:13 +05:30

19 KiB
Executable file

Multi-User and Collaboration Testing

When to use: When your application involves real-time collaboration, multi-user workflows, or any scenario where two or more users interact with the same resource simultaneously -- chat apps, shared documents, multiplayer features, admin-and-user flows. Prerequisites: core/fixtures-and-hooks.md, core/configuration.md

Quick Reference

// Two independent browser contexts in one test = two users
const alice = await browser.newContext({ storageState: 'auth/alice.json' });
const bob = await browser.newContext({ storageState: 'auth/bob.json' });
const alicePage = await alice.newPage();
const bobPage = await bob.newPage();

// Each operates independently — different cookies, sessions, localStorage
await alicePage.goto('/chat/room-1');
await bobPage.goto('/chat/room-1');

Patterns

Two Users in One Test via Browser Contexts

Use when: You need to verify that actions by one user are visible to another in real time. Avoid when: You only need to test a single user's flow. One context per test is the default.

Each browser.newContext() creates a fully isolated session -- separate cookies, localStorage, and network state. This is how you simulate two logged-in users in a single test without launching a second browser.

TypeScript

import { test, expect } from '@playwright/test';

test('alice sends a message and bob sees it', async ({ browser }) => {
  // Create two isolated contexts
  const aliceContext = await browser.newContext({ storageState: 'auth/alice.json' });
  const bobContext = await browser.newContext({ storageState: 'auth/bob.json' });

  const alicePage = await aliceContext.newPage();
  const bobPage = await bobContext.newPage();

  // Both navigate to the same chat room
  await alicePage.goto('/chat/general');
  await bobPage.goto('/chat/general');

  // Alice sends a message
  await alicePage.getByRole('textbox', { name: 'Message' }).fill('Hello Bob!');
  await alicePage.getByRole('button', { name: 'Send' }).click();

  // Bob sees it in real time
  await expect(bobPage.getByText('Hello Bob!')).toBeVisible();

  // Alice also sees her own message
  await expect(alicePage.getByText('Hello Bob!')).toBeVisible();

  // Cleanup
  await aliceContext.close();
  await bobContext.close();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('alice sends a message and bob sees it', async ({ browser }) => {
  const aliceContext = await browser.newContext({ storageState: 'auth/alice.json' });
  const bobContext = await browser.newContext({ storageState: 'auth/bob.json' });

  const alicePage = await aliceContext.newPage();
  const bobPage = await bobContext.newPage();

  await alicePage.goto('/chat/general');
  await bobPage.goto('/chat/general');

  await alicePage.getByRole('textbox', { name: 'Message' }).fill('Hello Bob!');
  await alicePage.getByRole('button', { name: 'Send' }).click();

  await expect(bobPage.getByText('Hello Bob!')).toBeVisible();
  await expect(alicePage.getByText('Hello Bob!')).toBeVisible();

  await aliceContext.close();
  await bobContext.close();
});

Multi-User Fixture for Reusability

Use when: Multiple tests need two-user setups. Wrap context creation in a fixture to avoid boilerplate. Avoid when: Only one test needs multi-user logic.

TypeScript

// fixtures.ts
import { test as base, expect, BrowserContext, Page } from '@playwright/test';

type MultiUserFixtures = {
  aliceContext: BrowserContext;
  alicePage: Page;
  bobContext: BrowserContext;
  bobPage: Page;
};

export const test = base.extend<MultiUserFixtures>({
  aliceContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth/alice.json' });
    await use(context);
    await context.close();
  },
  alicePage: async ({ aliceContext }, use) => {
    const page = await aliceContext.newPage();
    await use(page);
  },
  bobContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth/bob.json' });
    await use(context);
    await context.close();
  },
  bobPage: async ({ bobContext }, use) => {
    const page = await bobContext.newPage();
    await use(page);
  },
});

export { expect };
// collaboration.spec.ts
import { test, expect } from './fixtures';

test('both users see shared document title', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/docs/shared-doc');
  await bobPage.goto('/docs/shared-doc');

  await alicePage.getByRole('textbox', { name: 'Title' }).fill('Project Plan');

  await expect(bobPage.getByRole('textbox', { name: 'Title' })).toHaveValue('Project Plan');
});

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  aliceContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth/alice.json' });
    await use(context);
    await context.close();
  },
  alicePage: async ({ aliceContext }, use) => {
    const page = await aliceContext.newPage();
    await use(page);
  },
  bobContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth/bob.json' });
    await use(context);
    await context.close();
  },
  bobPage: async ({ bobContext }, use) => {
    const page = await bobContext.newPage();
    await use(page);
  },
});

module.exports = { test, expect };

Collaborative Editing with Conflict Detection

Use when: Testing real-time collaborative editors (Google Docs-style) where both users edit the same content. Avoid when: Your app does not support concurrent editing.

TypeScript

import { test, expect } from './fixtures';

test('concurrent edits merge without data loss', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/docs/shared-doc');
  await bobPage.goto('/docs/shared-doc');

  // Wait for both to be connected
  await expect(alicePage.getByTestId('connection-status')).toHaveText('Connected');
  await expect(bobPage.getByTestId('connection-status')).toHaveText('Connected');

  // Alice types at the beginning
  const aliceEditor = alicePage.getByRole('textbox', { name: 'Editor' });
  await aliceEditor.pressSequentially('Alice was here. ');

  // Bob types at the end (simultaneously)
  const bobEditor = bobPage.getByRole('textbox', { name: 'Editor' });
  await bobEditor.press('End');
  await bobEditor.pressSequentially('Bob was here.');

  // Both edits should be visible to both users
  await expect(aliceEditor).toContainText('Alice was here.');
  await expect(aliceEditor).toContainText('Bob was here.');
  await expect(bobEditor).toContainText('Alice was here.');
  await expect(bobEditor).toContainText('Bob was here.');
});

JavaScript

const { test, expect } = require('./fixtures');

test('concurrent edits merge without data loss', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/docs/shared-doc');
  await bobPage.goto('/docs/shared-doc');

  await expect(alicePage.getByTestId('connection-status')).toHaveText('Connected');
  await expect(bobPage.getByTestId('connection-status')).toHaveText('Connected');

  const aliceEditor = alicePage.getByRole('textbox', { name: 'Editor' });
  await aliceEditor.pressSequentially('Alice was here. ');

  const bobEditor = bobPage.getByRole('textbox', { name: 'Editor' });
  await bobEditor.press('End');
  await bobEditor.pressSequentially('Bob was here.');

  await expect(aliceEditor).toContainText('Alice was here.');
  await expect(aliceEditor).toContainText('Bob was here.');
  await expect(bobEditor).toContainText('Alice was here.');
  await expect(bobEditor).toContainText('Bob was here.');
});

Shared State Verification (Presence, Cursors, Indicators)

Use when: Verifying that one user's presence or activity is reflected in the other user's UI -- online indicators, typing indicators, cursor positions. Avoid when: Presence is not a feature of your app.

TypeScript

import { test, expect } from './fixtures';

test('bob sees alice typing indicator', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/chat/general');
  await bobPage.goto('/chat/general');

  // Alice starts typing
  await alicePage.getByRole('textbox', { name: 'Message' }).pressSequentially('Hel', { delay: 100 });

  // Bob sees the typing indicator
  await expect(bobPage.getByText('Alice is typing...')).toBeVisible();

  // Alice stops typing — indicator disappears after debounce
  await expect(bobPage.getByText('Alice is typing...')).toBeHidden({ timeout: 5000 });
});

test('online users list updates when user joins', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/chat/general');

  // Alice sees only herself
  await expect(alicePage.getByTestId('online-users').getByText('Alice')).toBeVisible();
  await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeHidden();

  // Bob joins
  await bobPage.goto('/chat/general');

  // Alice now sees Bob in the online list
  await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeVisible();
});

JavaScript

const { test, expect } = require('./fixtures');

test('bob sees alice typing indicator', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/chat/general');
  await bobPage.goto('/chat/general');

  await alicePage.getByRole('textbox', { name: 'Message' }).pressSequentially('Hel', { delay: 100 });
  await expect(bobPage.getByText('Alice is typing...')).toBeVisible();
  await expect(bobPage.getByText('Alice is typing...')).toBeHidden({ timeout: 5000 });
});

test('online users list updates when user joins', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/chat/general');
  await expect(alicePage.getByTestId('online-users').getByText('Alice')).toBeVisible();

  await bobPage.goto('/chat/general');
  await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeVisible();
});

Race Condition Testing Between Users

Use when: You need to verify that simultaneous actions (two users clicking "buy" on the last item, or two users editing the same field) are handled correctly. Avoid when: Your app has no shared mutable resources.

TypeScript

import { test, expect } from './fixtures';

test('only one user can claim the last item', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/store/limited-item');
  await bobPage.goto('/store/limited-item');

  // Both see the item is available
  await expect(alicePage.getByText('1 remaining')).toBeVisible();
  await expect(bobPage.getByText('1 remaining')).toBeVisible();

  // Both click buy at approximately the same time
  const aliceBuy = alicePage.getByRole('button', { name: 'Buy now' }).click();
  const bobBuy = bobPage.getByRole('button', { name: 'Buy now' }).click();
  await Promise.all([aliceBuy, bobBuy]);

  // One succeeds, one fails — verify the app handles the race
  const aliceResult = await alicePage.getByTestId('purchase-result').textContent();
  const bobResult = await bobPage.getByTestId('purchase-result').textContent();

  const results = [aliceResult, bobResult];
  expect(results).toContain('Purchase successful');
  expect(results).toContain('Item no longer available');
});

test('simultaneous form submissions do not create duplicates', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/admin/settings');
  await bobPage.goto('/admin/settings');

  // Both change the same setting
  await alicePage.getByLabel('Company name').fill('Alice Corp');
  await bobPage.getByLabel('Company name').fill('Bob Inc');

  // Submit simultaneously
  await Promise.all([
    alicePage.getByRole('button', { name: 'Save' }).click(),
    bobPage.getByRole('button', { name: 'Save' }).click(),
  ]);

  // Reload both pages — one value should win, no corruption
  await alicePage.reload();
  const finalValue = await alicePage.getByLabel('Company name').inputValue();
  expect(['Alice Corp', 'Bob Inc']).toContain(finalValue);

  // The "loser" should see a conflict notification or the updated value
  await bobPage.reload();
  await expect(bobPage.getByLabel('Company name')).toHaveValue(finalValue);
});

JavaScript

const { test, expect } = require('./fixtures');

test('only one user can claim the last item', async ({ alicePage, bobPage }) => {
  await alicePage.goto('/store/limited-item');
  await bobPage.goto('/store/limited-item');

  await expect(alicePage.getByText('1 remaining')).toBeVisible();
  await expect(bobPage.getByText('1 remaining')).toBeVisible();

  const aliceBuy = alicePage.getByRole('button', { name: 'Buy now' }).click();
  const bobBuy = bobPage.getByRole('button', { name: 'Buy now' }).click();
  await Promise.all([aliceBuy, bobBuy]);

  const aliceResult = await alicePage.getByTestId('purchase-result').textContent();
  const bobResult = await bobPage.getByTestId('purchase-result').textContent();

  const results = [aliceResult, bobResult];
  expect(results).toContain('Purchase successful');
  expect(results).toContain('Item no longer available');
});

N Users with Dynamic Context Creation

Use when: You need more than two users, or the number of users is variable (load-like collaboration tests). Avoid when: Two users suffice. Keep it simple.

TypeScript

import { test, expect, Browser, Page } from '@playwright/test';

async function createUser(browser: Browser, name: string, room: string): Promise<Page> {
  const context = await browser.newContext({ storageState: `auth/${name}.json` });
  const page = await context.newPage();
  await page.goto(`/chat/${room}`);
  return page;
}

test('five users in a chat room all see each other', async ({ browser }) => {
  const names = ['alice', 'bob', 'charlie', 'diana', 'eve'];
  const pages = await Promise.all(
    names.map((name) => createUser(browser, name, 'team-room'))
  );

  // First user sends a message
  await pages[0].getByRole('textbox', { name: 'Message' }).fill('Hello everyone!');
  await pages[0].getByRole('button', { name: 'Send' }).click();

  // All users see the message
  for (const page of pages) {
    await expect(page.getByText('Hello everyone!')).toBeVisible();
  }

  // Cleanup
  for (const page of pages) {
    await page.context().close();
  }
});

JavaScript

const { test, expect } = require('@playwright/test');

async function createUser(browser, name, room) {
  const context = await browser.newContext({ storageState: `auth/${name}.json` });
  const page = await context.newPage();
  await page.goto(`/chat/${room}`);
  return page;
}

test('five users in a chat room all see each other', async ({ browser }) => {
  const names = ['alice', 'bob', 'charlie', 'diana', 'eve'];
  const pages = await Promise.all(
    names.map((name) => createUser(browser, name, 'team-room'))
  );

  await pages[0].getByRole('textbox', { name: 'Message' }).fill('Hello everyone!');
  await pages[0].getByRole('button', { name: 'Send' }).click();

  for (const page of pages) {
    await expect(page.getByText('Hello everyone!')).toBeVisible();
  }

  for (const page of pages) {
    await page.context().close();
  }
});

Decision Guide

Scenario Approach Why
Two users interact in real time Two browser.newContext() in one test Fully isolated sessions, same test timeline
Same multi-user setup across many tests Custom fixture per user context Eliminates boilerplate, guarantees cleanup
Testing presence/typing indicators Sequential actions, assert on other page Order matters -- act on one, assert on the other
Race condition (simultaneous clicks) Promise.all([action1, action2]) Fires both as close to simultaneously as possible
Admin performs action, user sees result Two contexts with different storageState Different auth roles, same browser instance
3+ users in one test Loop with browser.newContext() per user Each context is cheap; browser is shared
Cross-browser multi-user (Chrome + Firefox) Separate browser launches in beforeAll Rarely needed; contexts within one browser suffice

Anti-Patterns

Don't Do This Problem Do This Instead
Use one context with two pages for two users Pages in the same context share cookies and localStorage Use browser.newContext() per user
Open a second browser for the second user Wastes memory and startup time Use a second browser.newContext() -- contexts are lightweight
await page.waitForTimeout(2000) for real-time sync Arbitrary delay; flaky in CI, slow in dev await expect(bobPage.getByText('msg')).toBeVisible()
Share mutable state via let variables between contexts Test order and timing become implicit dependencies Each context is independent; assert via UI
Put multi-user setup in beforeAll beforeAll cannot access page or context Use test-scoped fixtures or inline browser.newContext()
Forget to close extra contexts Leaks memory and may cause port exhaustion Always close contexts in fixture teardown or finally
Assert on both pages without waiting The second page may not have received the update yet Use expect with auto-retry on the receiving page

Troubleshooting

Symptom Cause Fix
Second user sees stale data Context created before server state was ready Navigate the second page after the first user's action completes
storageState file not found Auth setup project has not run, or path is wrong Run auth setup first via dependencies in playwright.config
Both users have the same session Reusing the same storageState file for both Create distinct auth state files per user role
Real-time updates never arrive on second page WebSocket/SSE connection not established before assertion Wait for a connection indicator before asserting: await expect(page.getByTestId('connected')).toBeVisible()
Test is slow with many contexts Too many contexts in a single test (10+) Limit to 3-5 contexts per test; use API setup to reduce page interactions
Promise.all race test always has same winner Network/event loop makes one always faster This is expected in testing -- assert that the app handles both outcomes, not which user wins