.agents/skills/playwright-best-practices/advanced/multi-user.md
test("two users see each other's changes", async ({browser}) => {
// Create two isolated contexts (like two browsers)
const userAContext = await browser.newContext()
const userBContext = await browser.newContext()
const userAPage = await userAContext.newPage()
const userBPage = await userBContext.newPage()
// Both users go to the same document
await userAPage.goto('/doc/shared-123')
await userBPage.goto('/doc/shared-123')
// User A types
await userAPage.getByLabel('Content').fill('Hello from User A')
// User B should see the change
await expect(userBPage.getByText('Hello from User A')).toBeVisible()
// Cleanup
await userAContext.close()
await userBContext.close()
})
test('admin and user interaction', async ({browser}) => {
// Load different auth states
const adminContext = await browser.newContext({
storageState: '.auth/admin.json',
})
const userContext = await browser.newContext({
storageState: '.auth/user.json',
})
const adminPage = await adminContext.newPage()
const userPage = await userContext.newPage()
// User submits request
await userPage.goto('/support')
await userPage.getByLabel('Message').fill('Need help!')
await userPage.getByRole('button', {name: 'Submit'}).click()
// Admin sees and responds
await adminPage.goto('/admin/tickets')
await expect(adminPage.getByText('Need help!')).toBeVisible()
await adminPage.getByRole('button', {name: 'Reply'}).click()
await adminPage.getByLabel('Response').fill('How can I help?')
await adminPage.getByRole('button', {name: 'Send'}).click()
// User sees response
await expect(userPage.getByText('How can I help?')).toBeVisible()
await adminContext.close()
await userContext.close()
})
// fixtures/multi-user.fixture.ts
import {test as base, Browser, BrowserContext, Page} from '@playwright/test'
type UserSession = {
context: BrowserContext
page: Page
}
type MultiUserFixtures = {
createUser: (authState?: string) => Promise<UserSession>
}
export const test = base.extend<MultiUserFixtures>({
createUser: async ({browser}, use) => {
const sessions: UserSession[] = []
await use(async (authState) => {
const context = await browser.newContext({
storageState: authState,
})
const page = await context.newPage()
sessions.push({context, page})
return {context, page}
})
// Cleanup all sessions
for (const session of sessions) {
await session.context.close()
}
},
})
// Usage
test('3 users collaborate', async ({createUser}) => {
const alice = await createUser('.auth/alice.json')
const bob = await createUser('.auth/bob.json')
const charlie = await createUser('.auth/charlie.json')
// All navigate to same room
await alice.page.goto('/room/123')
await bob.page.goto('/room/123')
await charlie.page.goto('/room/123')
// Test interactions...
})
test('real-time collaborative editing', async ({browser}) => {
const user1 = await browser.newContext()
const user2 = await browser.newContext()
const page1 = await user1.newPage()
const page2 = await user2.newPage()
await page1.goto('/docs/shared')
await page2.goto('/docs/shared')
// User 1 types at the beginning
const editor1 = page1.getByRole('textbox')
await editor1.click()
await editor1.press('Home')
await editor1.type('User 1: ')
// User 2 types at the end
const editor2 = page2.getByRole('textbox')
await editor2.click()
await editor2.press('End')
await editor2.type(' - User 2')
// Both should see combined result
await expect(page1.getByRole('textbox')).toContainText('User 1:')
await expect(page1.getByRole('textbox')).toContainText('- User 2')
await expect(page2.getByRole('textbox')).toContainText('User 1:')
await expect(page2.getByRole('textbox')).toContainText('- User 2')
await user1.close()
await user2.close()
})
test('shows other user cursors', async ({browser}) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const page1 = await ctx1.newPage()
const page2 = await ctx2.newPage()
// Mock to identify users
await page1.route('**/api/me', (route) => route.fulfill({json: {id: 'user-1', name: 'Alice'}}))
await page2.route('**/api/me', (route) => route.fulfill({json: {id: 'user-2', name: 'Bob'}}))
await page1.goto('/whiteboard/123')
await page2.goto('/whiteboard/123')
// Move cursor on page1
await page1.mouse.move(200, 200)
// Page2 should see Alice's cursor
await expect(page2.getByTestId('cursor-user-1')).toBeVisible()
await expect(page2.getByText('Alice')).toBeVisible()
await ctx1.close()
await ctx2.close()
})
const roles = [
{role: 'admin', canDelete: true, canEdit: true, canView: true},
{role: 'editor', canDelete: false, canEdit: true, canView: true},
{role: 'viewer', canDelete: false, canEdit: false, canView: true},
]
for (const {role, canDelete, canEdit, canView} of roles) {
test(`${role} permissions`, async ({browser}) => {
const context = await browser.newContext({
storageState: `.auth/${role}.json`,
})
const page = await context.newPage()
await page.goto('/document/123')
// Check view permission
if (canView) {
await expect(page.getByTestId('content')).toBeVisible()
} else {
await expect(page.getByText('Access denied')).toBeVisible()
}
// Check edit permission
const editButton = page.getByRole('button', {name: 'Edit'})
if (canEdit) {
await expect(editButton).toBeEnabled()
} else {
await expect(editButton).toBeDisabled()
}
// Check delete permission
const deleteButton = page.getByRole('button', {name: 'Delete'})
if (canDelete) {
await expect(deleteButton).toBeVisible()
} else {
await expect(deleteButton).toBeHidden()
}
await context.close()
})
}
test('cannot access admin routes as user', async ({browser}) => {
const userContext = await browser.newContext({
storageState: '.auth/user.json',
})
const page = await userContext.newPage()
// Try to access admin page directly
await page.goto('/admin/users')
// Should redirect or show error
await expect(page).not.toHaveURL('/admin/users')
await expect(page.getByText('Access denied')).toBeVisible()
await userContext.close()
})
test('handles concurrent edits', async ({browser}) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const page1 = await ctx1.newPage()
const page2 = await ctx2.newPage()
await page1.goto('/item/123')
await page2.goto('/item/123')
// Both click edit at the same time
await Promise.all([
page1.getByRole('button', {name: 'Edit'}).click(),
page2.getByRole('button', {name: 'Edit'}).click(),
])
// Both try to save different values
await page1.getByLabel('Name').fill('Value from User 1')
await page2.getByLabel('Name').fill('Value from User 2')
await Promise.all([
page1.getByRole('button', {name: 'Save'}).click(),
page2.getByRole('button', {name: 'Save'}).click(),
])
// One should succeed, one should get conflict error
const page1HasConflict = await page1.getByText('Conflict').isVisible()
const page2HasConflict = await page2.getByText('Conflict').isVisible()
// Exactly one should have conflict
expect(page1HasConflict || page2HasConflict).toBe(true)
expect(page1HasConflict && page2HasConflict).toBe(false)
await ctx1.close()
await ctx2.close()
})
test('optimistic locking prevents overwrites', async ({browser}) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const page1 = await ctx1.newPage()
const page2 = await ctx2.newPage()
// Both load the same version
await page1.goto('/record/123')
await page2.goto('/record/123')
// User 1 edits and saves first
await page1.getByRole('button', {name: 'Edit'}).click()
await page1.getByLabel('Value').fill('Updated by User 1')
await page1.getByRole('button', {name: 'Save'}).click()
await expect(page1.getByText('Saved')).toBeVisible()
// User 2 tries to save with stale version
await page2.getByRole('button', {name: 'Edit'}).click()
await page2.getByLabel('Value').fill('Updated by User 2')
await page2.getByRole('button', {name: 'Save'}).click()
// Should fail with version conflict
await expect(page2.getByText('Someone else modified this')).toBeVisible()
await expect(page2.getByRole('button', {name: 'Reload'})).toBeVisible()
await ctx1.close()
await ctx2.close()
})
test('chat messages sync between users', async ({browser}) => {
const aliceCtx = await browser.newContext()
const bobCtx = await browser.newContext()
const alicePage = await aliceCtx.newPage()
const bobPage = await bobCtx.newPage()
// Setup user identities
await alicePage.route('**/api/me', (r) => r.fulfill({json: {name: 'Alice'}}))
await bobPage.route('**/api/me', (r) => r.fulfill({json: {name: 'Bob'}}))
await alicePage.goto('/chat/room-1')
await bobPage.goto('/chat/room-1')
// Alice sends message
await alicePage.getByLabel('Message').fill('Hi Bob!')
await alicePage.getByRole('button', {name: 'Send'}).click()
// Bob sees it
await expect(bobPage.getByText('Alice: Hi Bob!')).toBeVisible()
// Bob replies
await bobPage.getByLabel('Message').fill('Hey Alice!')
await bobPage.getByRole('button', {name: 'Send'}).click()
// Alice sees it
await expect(alicePage.getByText('Bob: Hey Alice!')).toBeVisible()
await aliceCtx.close()
await bobCtx.close()
})
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Sharing context between users | State leaks, not isolated | Create separate contexts |
| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
| Hardcoded timing for sync | Flaky tests | Use expect().toBeVisible() |
| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |