Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/chatroom-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Chatroom Browser Tests

# Enable automatic PR coverage after configuring NEXT_PUBLIC_VIRTUOSO_LICENSE.
# No deployment or database credentials are used by this isolated suite.
on:
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: chatroom-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
chatroom:
name: Chatroom E2E
runs-on: ubuntu-latest
timeout-minutes: 25
env:
NEXT_PUBLIC_VIRTUOSO_LICENSE: ${{ secrets.NEXT_PUBLIC_VIRTUOSO_LICENSE }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '26.8.2'
- uses: ./.github/actions/setup-bun
with:
bun-version: '1.4.1'
- uses: ./.github/actions/setup-cypress
- name: Run the isolated chatroom browser suite
run: bash scripts/test-chatroom.sh
- name: Retain results and failure screenshots
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: chatroom-e2e-${{ github.run_id }}
include-hidden-files: true
path: |
Notes/test-results-*.txt
Notes/.e2e-logs-*/
Notes/chatroom-server.log
apps/webapp/cypress/screenshots/
retention-days: 7
49 changes: 49 additions & 0 deletions apps/webapp/cypress/e2e/chatroom/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Chatroom browser tests

These nine specs render the real chatroom UI against intercepted Supabase HTTP responses.
They cover navigation, pagination, message submission and retry, attachments, gallery behavior, and mobile quick reactions.

## Run locally

The workflow uses Bun 1.4.1 and Node 26.8.2, matching the validated local run.
Install workspace dependencies with `bun install --frozen-lockfile`.
Provide a valid Virtuoso Message List license through `NEXT_PUBLIC_VIRTUOSO_LICENSE`, then run:

```sh
bash scripts/test-chatroom.sh
```

The script builds the extensions and an explicitly opted-in production E2E webapp.
It starts a temporary standalone server on port 3211 and stops that server after the tests.
Set `CHATROOM_PORT` to use another free port.
Keep other Next servers in this checkout stopped while the build runs.

The test environment uses localhost service URLs and dummy authentication.
The shared fixtures seed the `sb-localhost-auth-token` cookie and intercept the HTTP requests.
No running Supabase, Redis, or collaboration server is required.
These tests do not verify database policies, storage persistence, or realtime delivery between clients.

For a prepared E2E server, the shared runner also accepts:

```sh
BASE_URL=http://127.0.0.1:3211 CYPRESS_PARALLEL=1 CI=true \
bash scripts/run-tests.sh --e2e --scope chatroom
```

Reports and worker logs are written under `Notes/`.
Scoped runs leave the full-suite timing data unchanged.

## CI and remaining coverage

The `Chatroom Browser Tests` workflow provides a manual run and uploads reports and failure screenshots.
Configure the repository Actions secret `NEXT_PUBLIC_VIRTUOSO_LICENSE` before running it.
The job fails clearly when the license is absent.
Automatic PR coverage remains pending that configuration and a successful hosted run.

The existing media-filter case remains pending because its control is currently unmounted.
The reaction test verifies the held-touch menu and the submitted emoji payload.
Realtime reaction-count updates and the first-send server echo need separate integration coverage.
The production E2E build retains Cypress selectors and disables PWA registration only when `NEXT_PUBLIC_E2E=true`.
Worker activation otherwise reloads the fixture page during interactions.
Service-worker lifecycle behavior needs separate PWA coverage.
Normal production builds retain their existing selector removal and E2E-route exclusion.
111 changes: 43 additions & 68 deletions apps/webapp/cypress/e2e/chatroom/attachments.cy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/// <reference types="cypress" />

import { stubChatroom } from '../../support/chatroomFixtures'

beforeEach(stubChatroom)

describe('chatroom attachments', () => {
const storagePath = 'user-1/channel-1/test.png'
const imageDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z4EPDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='

const channelAggregateBody = {
channel_info: {
Expand Down Expand Up @@ -64,34 +66,6 @@ describe('chatroom attachments', () => {
...overrides
})

const seedSupabaseAuthSession = () => {
cy.window().then((win) => {
win.localStorage.setItem(
'sb-docsplus_supabase-auth-token',
JSON.stringify({
access_token: 'e2e-access-token',
refresh_token: 'e2e-refresh-token',
expires_in: 3600,
expires_at: Math.floor(Date.now() / 1000) + 3600,
token_type: 'bearer',
user: { id: 'user-1', aud: 'authenticated', role: 'authenticated' }
})
)
})
}

/** Drag-and-drop tests leave IDB attachment drafts that block Send on the next case. */
const clearComposerDrafts = () => {
cy.window().then((win) => {
return new Promise<void>((resolve) => {
const request = win.indexedDB.deleteDatabase('chatApp')
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => resolve()
})
})
}

const stubStorage = () => {
cy.intercept({ method: /POST|PUT/, url: '**/storage/v1/object/media/**' }, (req) => {
req.reply({
Expand All @@ -101,17 +75,26 @@ describe('chatroom attachments', () => {
})
}).as('storageUpload')

// Storage returns a relative signed path, which supabase-js prefixes with its URL.
// Returning a data URL here creates an invalid prefixed URL and intermittent image failures.
cy.intercept('POST', '**/storage/v1/object/sign/media/**', (req) => {
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const rawPath = body?.paths?.[0] ?? body?.path ?? storagePath
const signedUrl = /\.(png|jpe?g|gif|webp)$/i.test(rawPath)
? imageDataUrl
: `https://example.test/signed/${rawPath}`
req.reply({
statusCode: 200,
body: { signedURL: signedUrl, signedUrl }
})
const path = new URL(req.url).pathname.replace('/storage/v1', '')
req.reply({ statusCode: 200, body: { signedURL: `${path}?token=test` } })
}).as('storageSign')
cy.intercept('GET', '**/storage/v1/object/sign/media/**', (req) => {
if (/\.(png|jpe?g|gif|webp)(\?|$)/i.test(req.url)) {
req.reply({
fixture: '../../public/icons/favicon-32x32.png',
headers: { 'content-type': 'image/png' }
})
} else {
req.reply({
statusCode: 200,
body: '',
headers: { 'content-type': 'application/octet-stream' }
})
}
})
}

const stubChannelAggregate = () => {
Expand Down Expand Up @@ -153,7 +136,6 @@ describe('chatroom attachments', () => {
stubMessageWindow(rows)
stubChannelAggregate()
stubStorage()
seedSupabaseAuthSession()
cy.visit('/c/test-channel')
cy.wait('@channelAggregate')
cy.wait('@messageWindow')
Expand Down Expand Up @@ -253,25 +235,17 @@ describe('chatroom attachments', () => {
cy.wait('@messageInsert', { timeout: 15_000 })
}

const registerUncaughtHandler = () => {
cy.on('uncaught:exception', (err) => {
if (err.message.includes('ResizeObserver loop')) return false
})
}

describe('composer', () => {
beforeEach(() => {
registerUncaughtHandler()
stubStorage()
stubMessageInsert()
stubChannelAggregate()
stubMessageWindow([])
seedSupabaseAuthSession()
clearComposerDrafts()
cy.visit('/c/test-channel')
cy.wait('@channelAggregate')
cy.wait('@messageWindow')
cy.waitForMessage('chatroom-feed')
cy.get('.message-feed > .absolute').should('not.exist')
cy.window().its('__chatTestApi').should('exist')
cy.window().then((win) => {
win.__chatTestApi?.resetComposerAttachments?.()
Expand Down Expand Up @@ -391,13 +365,18 @@ describe('chatroom attachments', () => {
})

it('accepts pasted files in the composer', () => {
attachFixtureFile({
contents: Cypress.Buffer.from('89504e470d0a1a0a', 'hex'),
fileName: 'pasted.png',
mimeType: 'image/png',
lastModified: Date.now()
cy.get('[data-testid="composer-input"] .ProseMirror').then(($input) => {
const win = $input[0].ownerDocument.defaultView!
const clipboardData = new win.DataTransfer()
clipboardData.items.add(
new win.File([Cypress.Buffer.from('89504e470d0a1a0a', 'hex')], 'pasted.png', {
type: 'image/png'
})
)
$input[0].dispatchEvent(
new win.ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })
)
})

expectAttachmentVisible('pasted.png')
})

Expand All @@ -415,10 +394,6 @@ describe('chatroom attachments', () => {
})

describe('feed gallery', () => {
beforeEach(() => {
registerUncaughtHandler()
})

it('opens the root gallery when clicking a feed image', () => {
const messageId = 'feed-image-1'
visitFeed(
Expand Down Expand Up @@ -576,7 +551,10 @@ describe('chatroom attachments', () => {
cy.get('[data-testid="feed-video-poster"]').should('exist')
})
cy.get('[data-chat-media] [aria-label="Expand video"]').should('not.exist')
cy.get('[data-chat-media] [data-testid="feed-video-poster"]').click()
cy.get('[data-chat-media] [data-testid="feed-video-poster"] video').should('have.attr', 'src')
cy.get('[data-chat-media] [data-testid="feed-video-poster"]')
.should('be.visible')
.click({ scrollBehavior: false })
cy.get('[data-testid="chat-media-gallery"]').contains('2 / 2')
cy.get('[data-testid="chat-media-gallery"] video').should('exist')

Expand Down Expand Up @@ -658,13 +636,7 @@ describe('chatroom attachments', () => {

cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-spoiler-reveal"]`).should('exist')
cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-spoiler-reveal"]`).realClick()
cy.get(`[data-msg-id="${messageId}"]`).then(($msg) => {
if ($msg.find('[data-testid="feed-image-open"]').length === 0) {
cy.window().then((win) => {
win.__chatTestApi?.revealFeedSpoiler?.(storagePath)
})
}
})
cy.get('[data-testid="chat-media-gallery"]').should('not.exist')
cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-image-open"]`, {
timeout: 15_000
}).should('be.visible')
Expand All @@ -691,7 +663,10 @@ describe('chatroom attachments', () => {
waitForStorageSignIfPending()
assertVideoPosterReady()
cy.get('[data-chat-media] [data-media-layout="mosaic"]').should('exist')
cy.get('[data-chat-media] [data-testid="feed-video-poster"]').click({ force: true })
cy.get('[data-chat-media] [data-testid="feed-video-poster"] video').should('have.attr', 'src')
cy.get('[data-chat-media] [data-testid="feed-video-poster"]')
.should('be.visible')
.click({ scrollBehavior: false })
cy.get('[data-testid="chat-media-gallery"]').contains('2 / 2')

cy.get('[data-testid="chat-media-gallery"] [aria-label="Previous media"]').click()
Expand Down
9 changes: 8 additions & 1 deletion apps/webapp/cypress/e2e/chatroom/deep-link.cy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/// <reference types="cypress" />

import { stubChatroom } from '../../support/chatroomFixtures'

beforeEach(stubChatroom)

describe('chatroom deep link', () => {
it('mounts with the target message visible and flashed', () => {
const targetId = 'msg-deep-link'
Expand All @@ -13,7 +17,10 @@ describe('chatroom deep link', () => {
}
}).as('window')
cy.visit(`/c/test-channel?msg=${targetId}`)
cy.wait('@window')
.its('request.body')
.should('include', { p_anchor_kind: 'message_id', p_anchor_value: targetId })
cy.waitForMessage(targetId)
cy.get('.msg_card--flash').should('be.visible')
cy.waitForMessage(targetId).should('have.class', 'msg_card--flash')
})
})
8 changes: 7 additions & 1 deletion apps/webapp/cypress/e2e/chatroom/jump-to-present.cy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/// <reference types="cypress" />

import { stubChatroom } from '../../support/chatroomFixtures'

beforeEach(stubChatroom)

describe('chatroom jump to present', () => {
beforeEach(() => {
cy.visit('/c/test-channel?msg=msg-deep-link')
Expand All @@ -8,8 +12,10 @@ describe('chatroom jump to present', () => {

it('shows the floating button while detached and returns to live tail on tap', () => {
cy.get('[data-key="jump-to-present"]').should('be.visible')
cy.get('[data-key="jump-count"]').should('exist')
cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*').as('presentWindow')
cy.get('[data-key="jump-to-present"]').click()
cy.wait('@presentWindow').its('request.body.p_anchor_kind').should('equal', 'tail')
cy.waitForMessage('message-40')
cy.get('[data-key="jump-to-present"]').should('not.exist')
})
})
38 changes: 25 additions & 13 deletions apps/webapp/cypress/e2e/chatroom/long-press-reaction.cy.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
/// <reference types="cypress" />

import { stubChatroom } from '../../support/chatroomFixtures'

beforeEach(stubChatroom)

describe('chatroom long-press reaction', () => {
beforeEach(() => {
it('opens quick reactions on a held touch and submits the chosen emoji', () => {
cy.viewport('iphone-x')
cy.visit('/c/test-channel')
cy.waitForMessage('chatroom-feed')
})

it('opens the reaction picker on long press and increments the count', () => {
cy.intercept('POST', '**/rest/v1/rpc/add_reaction*', {
statusCode: 200,
body: { reactions: { '👍': 1 } }
body: { '👍': [{ user_id: 'user-1', created_at: '2026-05-13T10:00:00Z' }] }
}).as('addReaction')

cy.get('.msg_card').first().realTouch({ position: 'center' }).wait(600)
cy.get('[data-key="long-press-menu"]', { timeout: 4000 }).should('be.visible')
cy.get('[data-key="reaction-picker"]').first().click()
cy.wait('@addReaction')
cy.contains('1').should('be.visible')
cy.visit('/c/test-channel?variant=mobile')
cy.waitForMessage('message-40')
// realTouch sends touchEnd immediately; keep this touch down past the 500ms threshold.
cy.get('[data-msg-id="message-40"] .chat-bubble').trigger('touchstart', {
touches: [{ identifier: 1, clientX: 150, clientY: 400 }],
changedTouches: [{ identifier: 1, clientX: 150, clientY: 400 }]
})
cy.get('[role="toolbar"][aria-label="Quick reactions"]').should('be.visible')
cy.get('[data-msg-id="message-40"][data-mode="inline"] .chat-bubble').trigger('touchend', {
touches: [],
changedTouches: [{ identifier: 1, clientX: 150, clientY: 400 }],
force: true
})
cy.get('[aria-label="React with Like"]').should('be.enabled').realClick()
cy.wait('@addReaction').its('request.body').should('deep.equal', {
p_message_id: 'message-40',
p_emoji: '👍'
})
cy.get('[role="toolbar"][aria-label="Quick reactions"]').should('not.exist')
})
})
Loading
Loading