diff --git a/.github/workflows/chatroom-e2e.yml b/.github/workflows/chatroom-e2e.yml
new file mode 100644
index 000000000..85748d82f
--- /dev/null
+++ b/.github/workflows/chatroom-e2e.yml
@@ -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
diff --git a/apps/webapp/cypress/e2e/chatroom/README.md b/apps/webapp/cypress/e2e/chatroom/README.md
new file mode 100644
index 000000000..70882c0b1
--- /dev/null
+++ b/apps/webapp/cypress/e2e/chatroom/README.md
@@ -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.
diff --git a/apps/webapp/cypress/e2e/chatroom/attachments.cy.ts b/apps/webapp/cypress/e2e/chatroom/attachments.cy.ts
index 61f18b3bd..172c7269d 100644
--- a/apps/webapp/cypress/e2e/chatroom/attachments.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/attachments.cy.ts
@@ -1,9 +1,11 @@
///
+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: {
@@ -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((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({
@@ -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 = () => {
@@ -153,7 +136,6 @@ describe('chatroom attachments', () => {
stubMessageWindow(rows)
stubChannelAggregate()
stubStorage()
- seedSupabaseAuthSession()
cy.visit('/c/test-channel')
cy.wait('@channelAggregate')
cy.wait('@messageWindow')
@@ -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?.()
@@ -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')
})
@@ -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(
@@ -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')
@@ -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')
@@ -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()
diff --git a/apps/webapp/cypress/e2e/chatroom/deep-link.cy.ts b/apps/webapp/cypress/e2e/chatroom/deep-link.cy.ts
index 83557a8ab..39958f0a1 100644
--- a/apps/webapp/cypress/e2e/chatroom/deep-link.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/deep-link.cy.ts
@@ -1,5 +1,9 @@
///
+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'
@@ -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')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/jump-to-present.cy.ts b/apps/webapp/cypress/e2e/chatroom/jump-to-present.cy.ts
index af511bd6b..1b6e7c9e8 100644
--- a/apps/webapp/cypress/e2e/chatroom/jump-to-present.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/jump-to-present.cy.ts
@@ -1,5 +1,9 @@
///
+import { stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom jump to present', () => {
beforeEach(() => {
cy.visit('/c/test-channel?msg=msg-deep-link')
@@ -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')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/long-press-reaction.cy.ts b/apps/webapp/cypress/e2e/chatroom/long-press-reaction.cy.ts
index 6e349e6bf..2304d9a9d 100644
--- a/apps/webapp/cypress/e2e/chatroom/long-press-reaction.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/long-press-reaction.cy.ts
@@ -1,22 +1,34 @@
///
+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')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/open-unread.cy.ts b/apps/webapp/cypress/e2e/chatroom/open-unread.cy.ts
index 9e093cb75..2286292cf 100644
--- a/apps/webapp/cypress/e2e/chatroom/open-unread.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/open-unread.cy.ts
@@ -1,5 +1,9 @@
///
+import { stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom open with unread', () => {
it('lands on the unread separator when first_unread anchor exists', () => {
cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*', {
@@ -15,6 +19,7 @@ describe('chatroom open with unread', () => {
}
}).as('window')
cy.visit('/c/test-channel')
- cy.get('[data-key^="unread-"]').should('be.visible')
+ cy.wait('@window').its('request.body.p_anchor_kind').should('equal', 'first_unread')
+ cy.get('[role="separator"][aria-label="New messages"]').should('be.visible')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/reply-jump-in-window.cy.ts b/apps/webapp/cypress/e2e/chatroom/reply-jump-in-window.cy.ts
index b24e35716..04f659ae7 100644
--- a/apps/webapp/cypress/e2e/chatroom/reply-jump-in-window.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/reply-jump-in-window.cy.ts
@@ -1,5 +1,9 @@
///
+import { stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom reply jump in-window', () => {
beforeEach(() => {
cy.visit('/c/test-channel')
@@ -7,7 +11,7 @@ describe('chatroom reply jump in-window', () => {
})
it('smooth-scrolls and flashes the target on reply-ref tap', () => {
- cy.get('[data-key^="reply-ref-"]').first().click()
- cy.get('.msg_card--flash').should('be.visible')
+ cy.get('[data-key="reply-ref-message-35"]').click()
+ cy.waitForMessage('message-35').should('have.class', 'msg_card--flash')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/reply-jump-out-of-window.cy.ts b/apps/webapp/cypress/e2e/chatroom/reply-jump-out-of-window.cy.ts
index 49324c011..06a689371 100644
--- a/apps/webapp/cypress/e2e/chatroom/reply-jump-out-of-window.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/reply-jump-out-of-window.cy.ts
@@ -1,23 +1,37 @@
///
+import { chatroomRows, stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom reply jump out-of-window', () => {
- beforeEach(() => {
+ it('fetches the off-window reply target and flashes it', () => {
+ const target = { ...chatroomRows[0], id: 'msg-off-window', content: 'Earlier reply target' }
+ cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*', (req) => {
+ const jumping = req.body.p_anchor_kind === 'message_id'
+ if (jumping) req.alias = 'targetWindow'
+ req.reply({
+ body: {
+ rows: jumping
+ ? [target]
+ : chatroomRows.map((row, index) =>
+ index === 39
+ ? {
+ ...row,
+ reply_to_message_id: target.id,
+ replied_message_preview: target.content
+ }
+ : row
+ ),
+ anchor_seq: jumping ? target.seq : null,
+ has_more_before: false,
+ has_more_after: jumping
+ }
+ })
+ })
cy.visit('/c/test-channel')
- cy.waitForMessage('chatroom-feed')
- })
-
- it('replaces data and lands on the off-window target', () => {
- const offWindowId = 'msg-off-window'
- cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*', {
- statusCode: 200,
- body: {
- rows: [{ id: offWindowId, seq: 42, created_at: '2026-05-13T10:00:00Z', content: 'off' }],
- anchor_seq: 42,
- has_more_before: false,
- has_more_after: true
- }
- }).as('window')
- cy.scrollToMessageViaApi(offWindowId)
- cy.get(`[data-key="${offWindowId}"]`).should('be.visible')
+ cy.get(`[data-key="reply-ref-${target.id}"]`).click()
+ cy.wait('@targetWindow').its('request.body.p_anchor_value').should('equal', target.id)
+ cy.waitForMessage(target.id).should('have.class', 'msg_card--flash')
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/scroll-and-prepend.cy.ts b/apps/webapp/cypress/e2e/chatroom/scroll-and-prepend.cy.ts
index c2d3d582a..a3671fabb 100644
--- a/apps/webapp/cypress/e2e/chatroom/scroll-and-prepend.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/scroll-and-prepend.cy.ts
@@ -1,23 +1,54 @@
///
+import { chatroomRows, stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom scroll and prepend', () => {
beforeEach(() => {
+ cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*', (req) => {
+ const older = req.body.p_anchor_kind === 'before_seq'
+ if (older) req.alias = 'olderWindow'
+ req.reply({
+ delay: older ? 300 : 0,
+ body: {
+ rows: older
+ ? [{ ...chatroomRows[0], id: 'older-message', seq: 0, content: 'Earlier message' }]
+ : chatroomRows,
+ anchor_seq: null,
+ has_more_before: !older,
+ has_more_after: false
+ }
+ })
+ })
cy.visit('/c/test-channel')
- cy.waitForMessage('chatroom-feed')
+ cy.waitForMessage('message-40')
+ cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top', { duration: 200 })
+ cy.get('[data-key="pagination-loader"]').should('be.visible')
+ cy.wait('@olderWindow').its('request.body.p_anchor_value').should('equal', '1')
+ cy.get('[data-key="pagination-loader"]').should('not.exist')
+ // Virtuoso measures the prepended rows, then compensates the scroll on two frames.
+ cy.window().then(
+ (win) =>
+ new Promise((resolve) =>
+ win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))
+ )
+ )
})
- it('loads older messages when scrolled to top', () => {
- cy.get('.message-feed').scrollTo('top', { duration: 200 })
- cy.get('[data-key="pagination-loader"]').should('exist')
+ it('prepends the older page without losing the loaded page', () => {
+ cy.scrollToMessageViaApi('older-message')
+ cy.waitForMessage('older-message')
+ cy.waitForMessage('message-1')
+ cy.get('[data-key="pagination-loader"]').should('not.exist')
})
- it('does not emit duplicate day separators across the join', () => {
- cy.get('.message-feed').scrollTo('top', { duration: 200 })
- cy.get('.date_chip').then(($chips) => {
- const dates = [...$chips].map((el) => el.textContent?.trim() ?? '')
- const unique = new Set(dates)
- // Allow re-use across natural day boundaries, just no immediate dupes.
- expect(dates.length).to.be.at.most(unique.size + 1)
- })
+ it('keeps exactly one day separator across a same-day page join', () => {
+ cy.scrollToMessageViaApi('older-message')
+ cy.waitForMessage('older-message')
+ cy.get('[data-testid="virtuoso-list"] .date_chip[data-msg-date="2026-05-13"]').should(
+ 'have.length',
+ 1
+ )
})
})
diff --git a/apps/webapp/cypress/e2e/chatroom/send-and-retry.cy.ts b/apps/webapp/cypress/e2e/chatroom/send-and-retry.cy.ts
index 11ddd79d3..72b67073c 100644
--- a/apps/webapp/cypress/e2e/chatroom/send-and-retry.cy.ts
+++ b/apps/webapp/cypress/e2e/chatroom/send-and-retry.cy.ts
@@ -1,27 +1,99 @@
///
+import { clearChatroomDrafts, stubChatroom } from '../../support/chatroomFixtures'
+
+beforeEach(stubChatroom)
+
describe('chatroom send and retry', () => {
beforeEach(() => {
cy.visit('/c/test-channel')
- cy.waitForMessage('chatroom-feed')
+ cy.waitForMessage('message-40')
+ })
+
+ it('clears persisted drafts from the app origin before the next visit', () => {
+ cy.get('[data-testid="composer-input"] .ProseMirror').should('be.visible')
+ cy.window().then(
+ (win) =>
+ new Promise((resolve, reject) => {
+ const request = win.indexedDB.open('chatApp')
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => {
+ const db = request.result
+ const transaction = db.transaction('composer', 'readwrite')
+ transaction.objectStore('composer').put({
+ workspaceId: 'e2e-workspace',
+ roomId: 'test-channel',
+ state: { text: 'Draft left by the previous case' },
+ updatedAt: Date.now()
+ })
+ transaction.oncomplete = () => {
+ db.close()
+ resolve()
+ }
+ transaction.onerror = () => {
+ db.close()
+ reject(transaction.error)
+ }
+ }
+ })
+ )
+ clearChatroomDrafts()
+ cy.window().then(async (win) => {
+ expect(win.location.origin).to.equal(new URL(Cypress.config('baseUrl')!).origin)
+ const databases = await win.indexedDB.databases()
+ expect(databases.map((database) => database.name)).not.to.include('chatApp')
+ })
+ cy.visit('/c/test-channel')
+ cy.waitForMessage('message-40')
+ cy.get('[data-testid="composer-input"]').should('have.text', '')
})
- it('sends a message and renders it', () => {
+ it('posts a message and renders the optimistic row', () => {
+ cy.intercept('POST', '**/rest/v1/messages*').as('send')
cy.get('[data-testid="composer-input"]').type('hello from cypress')
- cy.get('[data-testid="composer-primary-action"]').click()
- cy.contains('hello from cypress').should('be.visible')
+ cy.get('[data-testid="composer-primary-action"]')
+ .should('have.attr', 'aria-label', 'Send message')
+ .click()
+ cy.wait('@send').its('request.body.content').should('equal', 'hello from cypress')
+ cy.contains('.msg_card', 'hello from cypress').should('be.visible')
+ cy.get('[data-testid="composer-input"]').should('have.text', '')
})
- it('shows failed state on RLS error', () => {
+ it('retries an RLS failure with the original message id', () => {
cy.intercept('POST', '**/rest/v1/messages*', {
statusCode: 401,
body: { code: '42501' }
}).as('failedSend')
cy.get('[data-testid="composer-input"]').type('this will fail')
- cy.get('[data-testid="composer-primary-action"]').click()
+ cy.get('[data-testid="composer-primary-action"]')
+ .should('have.attr', 'aria-label', 'Send message')
+ .click()
cy.wait('@failedSend')
- cy.get('[data-status="failed"]').should('be.visible')
- cy.get('[data-testid="composer-input"]').should('contain.text', 'this will fail')
+ cy.contains('.msg_card', 'this will fail').within(() => {
+ cy.contains('Failed to send').should('be.visible')
+ cy.get('[aria-label="Retry sending message"]').should('be.visible')
+ })
+ cy.get('[data-testid="composer-input"]').should('have.text', '')
+ cy.get('@failedSend').then((failed) => {
+ const originalId = (failed as unknown as { request: { body: { id: string } } }).request.body
+ .id
+ cy.intercept('POST', '**/rest/v1/messages*', (req) => {
+ expect(req.body.id).to.equal(originalId)
+ expect(req.body.content).to.equal('this will fail')
+ req.reply({ statusCode: 201, body: [{ ...req.body, seq: 41 }] })
+ }).as('retrySend')
+ })
+ cy.get('[aria-label="Retry sending message"]')
+ .should('be.visible')
+ .click({ scrollBehavior: false })
+ cy.wait('@retrySend')
+ cy.contains('.msg_card', 'this will fail')
+ .should('be.visible')
+ .within(() => {
+ cy.contains('Failed to send').should('not.exist')
+ cy.get('[aria-label="Sending"]').should('not.exist')
+ cy.get('time').should('exist')
+ })
})
it('treats 23505 duplicate-key as success', () => {
@@ -30,8 +102,17 @@ describe('chatroom send and retry', () => {
body: { code: '23505', message: 'duplicate key value violates unique constraint' }
}).as('dupSend')
cy.get('[data-testid="composer-input"]').type('idempotent send')
- cy.get('[data-testid="composer-primary-action"]').click()
+ cy.get('[data-testid="composer-primary-action"]')
+ .should('have.attr', 'aria-label', 'Send message')
+ .click()
cy.wait('@dupSend')
- cy.get('[data-status="sent"]').should('exist')
+ cy.contains('.msg_card', 'idempotent send')
+ .should('be.visible')
+ .within(() => {
+ cy.contains('Failed to send').should('not.exist')
+ cy.get('[aria-label="Sending"]').should('not.exist')
+ cy.get('time').should('exist')
+ cy.get('[aria-label="Retry sending message"]').should('not.exist')
+ })
})
})
diff --git a/apps/webapp/cypress/support/chatroom.ts b/apps/webapp/cypress/support/chatroom.ts
index 6d62f092a..8fcbcd942 100644
--- a/apps/webapp/cypress/support/chatroom.ts
+++ b/apps/webapp/cypress/support/chatroom.ts
@@ -28,7 +28,7 @@ declare global {
}
Cypress.Commands.add('waitForMessage', (key: string) =>
- cy.get(`[data-key="${key}"]`, { timeout: 10_000 }).should('be.visible')
+ cy.get(`[data-msg-id="${key}"], [data-key="${key}"]`, { timeout: 10_000 }).should('be.visible')
)
Cypress.Commands.add('scrollToMessageViaApi', (id: string) =>
diff --git a/apps/webapp/cypress/support/chatroomFixtures.ts b/apps/webapp/cypress/support/chatroomFixtures.ts
new file mode 100644
index 000000000..a250efb13
--- /dev/null
+++ b/apps/webapp/cypress/support/chatroomFixtures.ts
@@ -0,0 +1,121 @@
+///
+
+const user = {
+ id: 'user-1',
+ aud: 'authenticated',
+ role: 'authenticated',
+ email: 'tester@example.test'
+}
+const profile = {
+ ...user,
+ username: 'tester',
+ full_name: 'Tester',
+ display_name: 'Tester',
+ status: 'ONLINE'
+}
+
+export const chatroomRows = Array.from({ length: 40 }, (_, i) => ({
+ id: i === 19 ? 'msg-deep-link' : `message-${i + 1}`,
+ seq: i + 1,
+ created_at: new Date(Date.UTC(2026, 4, 13, 9, i)).toISOString(),
+ channel_id: 'test-channel',
+ user_id: 'user-2',
+ user_details: { id: 'user-2', username: 'teammate', fullname: 'Teammate' },
+ content: `Message ${i + 1}`,
+ type: 'text',
+ reply_to_message_id: i === 39 ? 'message-35' : null,
+ replied_message_preview: i === 39 ? 'Message 35' : null,
+ reactions: {}
+}))
+
+export function clearChatroomDrafts() {
+ // Visit an inert document on the app origin: about:blank has no explicit origin,
+ // and visiting the real chatroom would reopen its database before deletion.
+ const resetPath = '/__chatroom_fixture_reset__'
+ cy.intercept('GET', resetPath, {
+ headers: { 'content-type': 'text/html' },
+ body: ''
+ })
+ cy.visit(resetPath)
+ return cy.window().then(
+ (win) =>
+ new Promise((resolve, reject) => {
+ const request = win.indexedDB.deleteDatabase('chatApp')
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error)
+ request.onblocked = () =>
+ reject(new Error('The previous chat draft database is still open'))
+ })
+ )
+}
+
+export function stubChatroom() {
+ // Cypress clears cookies/localStorage between cases, but IndexedDB drafts persist.
+ clearChatroomDrafts()
+ // Virtuoso measures items synchronously; these browser notifications are benign.
+ // https://virtuoso.dev/message-list/resize-observer-errors/
+ cy.on('uncaught:exception', (error) => {
+ if (
+ error.message.includes('ResizeObserver loop completed with undelivered notifications.') ||
+ error.message.includes('ResizeObserver loop limit exceeded')
+ ) {
+ return false
+ }
+ })
+ const encode = (value: unknown) =>
+ btoa(JSON.stringify(value)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
+ const expires = Math.floor(Date.now() / 1000) + 3600
+ const session = {
+ access_token: `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ sub: user.id, exp: expires, aud: 'authenticated', role: 'authenticated' })}.test-signature`,
+ refresh_token: 'test-refresh-token',
+ expires_at: expires,
+ expires_in: 3600,
+ token_type: 'bearer',
+ user
+ }
+ const base = new URL(Cypress.config('baseUrl')!)
+ cy.setCookie('sb-localhost-auth-token', `base64-${encode(session)}`, { domain: base.hostname })
+ cy.intercept('GET', '**/auth/v1/user*', { body: user })
+ cy.intercept('POST', '**/auth/v1/token*', { body: session })
+ cy.intercept({ method: /GET|PATCH/, url: '**/rest/v1/users*' }, { body: profile })
+ cy.intercept('POST', '**/rest/v1/rpc/get_channel_aggregate_data*', {
+ body: {
+ channel_info: {
+ id: 'test-channel',
+ type: 'PUBLIC',
+ name: 'test-channel',
+ slug: 'test-channel'
+ },
+ is_user_channel_member: true,
+ is_user_channel_owner: true,
+ is_user_channel_admin: true,
+ channel_member_info: { member_id: user.id, channel_id: 'test-channel', last_read_seq: 0 },
+ pinned_messages: [],
+ peer_max_read_seq: null,
+ last_read_seq: 0,
+ last_messages: [],
+ has_more_older: false,
+ has_more_newer: false
+ }
+ })
+ cy.intercept('POST', '**/rest/v1/rpc/fetch_message_window*', (req) => {
+ const before = req.body.p_anchor_kind === 'before_seq'
+ req.reply({
+ body: {
+ rows: before ? [] : chatroomRows,
+ anchor_seq: req.body.p_anchor_kind === 'message_id' ? 20 : null,
+ has_more_before: !before,
+ has_more_after: req.body.p_anchor_kind === 'message_id'
+ }
+ })
+ })
+ cy.intercept('POST', '**/rest/v1/rpc/get_channel_notif_state*', {
+ body: JSON.stringify('ALL'),
+ headers: { 'content-type': 'application/json' }
+ })
+ cy.intercept('POST', '**/rest/v1/rpc/fetch_messages_since*', { body: [] })
+ cy.intercept('POST', '**/rest/v1/rpc/advance_read_cursor*', { body: null })
+ cy.intercept('POST', '**/rest/v1/messages*', (req) => {
+ req.reply({ statusCode: 201, body: [{ ...req.body, seq: 41 }] })
+ })
+}
diff --git a/apps/webapp/next.config.js b/apps/webapp/next.config.js
index 527ff4207..84d70f552 100644
--- a/apps/webapp/next.config.js
+++ b/apps/webapp/next.config.js
@@ -12,13 +12,15 @@ const {
GLITCHTIP_CONNECT_HOSTS
} = require('./config/security/third-party-hosts')
const isProduction = process.env.NODE_ENV === 'production'
+const isE2E = process.env.NEXT_PUBLIC_E2E === 'true'
const isCoverageInstrumentation =
process.env.COVERAGE === 'true' || process.env.CYPRESS_COVERAGE === 'true'
const path = require('path')
const { createSecureHeaders } = require('next-secure-headers')
const withPWA = require('next-pwa')({
dest: 'public',
- disable: !isProduction,
+ // Fixture suites need a stable page; worker activation otherwise reloads mid-test.
+ disable: !isProduction || isE2E,
register: true,
skipWaiting: false,
// next-pwa defaults this on, and reloads the page the moment the network returns.
@@ -172,7 +174,8 @@ module.exports = withPWA({
// }
// : false,
// Enable React optimizations
- reactRemoveProperties: isProduction
+ // Keep Cypress selectors in the explicitly opted-in local/CI test build.
+ reactRemoveProperties: isProduction && !isE2E
},
// Production logging and monitoring
diff --git a/apps/webapp/src/pages/c/[channelId].tsx b/apps/webapp/src/pages/c/[channelId].tsx
index 4bcbff8e2..37f5926e5 100644
--- a/apps/webapp/src/pages/c/[channelId].tsx
+++ b/apps/webapp/src/pages/c/[channelId].tsx
@@ -1,6 +1,5 @@
import Chatroom from '@components/chatroom/Chatroom'
import { useAuthStore, useChatStore, useStore } from '@stores'
-import { supabaseClient } from '@utils/supabase'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
@@ -14,10 +13,7 @@ function bootstrapE2EChannel(channelId: string, fetchMsgsFromId?: string | null)
useAuthStore.getState().setProfile(e2eProfile)
useAuthStore.getState().setSession({ user: { id: 'user-1' } })
- void supabaseClient.auth.setSession({
- access_token: 'e2e-access-token',
- refresh_token: 'e2e-refresh-token'
- })
+ // Cypress seeds the browser session; do not replace it with an invalid JWT.
useChatStore
.getState()
@@ -62,7 +58,9 @@ export default function E2EChatroomPage() {
return (
-
+
E2E Chat
diff --git a/scripts/check-ci.ts b/scripts/check-ci.ts
index ab06b8336..b9dc91648 100644
--- a/scripts/check-ci.ts
+++ b/scripts/check-ci.ts
@@ -11,6 +11,7 @@ import { existsSync, lstatSync, rmSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { resolve } from 'path'
+import { nextServerConflict } from './check-next-server.ts'
import { PUBLISHABLE_EXTENSION_DIRS } from './publishable-extensions.ts'
const ROOT = resolve(import.meta.dir, '..')
@@ -99,14 +100,6 @@ function extensionsFor(changed: string[]): string[] {
)
}
-async function nextDevLive(): Promise {
- const result = await $`ps -ax -o command=`.quiet().nothrow()
- const text = result.stdout.toString()
- if (/\bnext\s+(dev|start)\b/.test(text))
- return 'Next dest is live; a production build would corrupt that .next'
- return null
-}
-
function discardNextBuild(appDir: string): void {
const dest = resolve(ROOT, appDir, '.next')
if (!existsSync(dest)) return
@@ -141,7 +134,7 @@ const subject = (await gitLines(['log', '-1', '--format=%s']))[0] ?? ''
const appDeploy = /^\(build\):\s/.test(subject) && /\b(back|front)\b/.test(subject)
const changed = await changedPaths()
const extMatrix = extensionsFor(changed)
-const destBlock = await nextDevLive()
+const destBlock = await nextServerConflict(ROOT)
console.log('check:ci — local replica of the prod quality gates')
console.log(`HEAD ${subject || '(no commits)'}`)
@@ -155,6 +148,9 @@ console.log('')
let failed = false
+if (!(await runGate('Next server guard', ['bun', 'test', 'scripts/check-next-server.test.ts']))) {
+ failed = true
+}
if (!(await runGate('lint', ['bun', 'run', 'lint']))) failed = true
if (!(await runGate('lint:styles', ['bun', 'run', 'lint:styles']))) failed = true
diff --git a/scripts/check-next-server.test.ts b/scripts/check-next-server.test.ts
new file mode 100644
index 000000000..33c347f52
--- /dev/null
+++ b/scripts/check-next-server.test.ts
@@ -0,0 +1,137 @@
+import { afterAll, expect, test } from 'bun:test'
+import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+
+import { nextServerConflict } from './check-next-server'
+
+const fixture = mkdtempSync(join(tmpdir(), 'next-checkout-guard-'))
+const checkout = join(fixture, 'repo')
+const otherCheckout = join(fixture, 'repo-other')
+mkdirSync(checkout)
+mkdirSync(otherCheckout)
+const probe = join(fixture, 'server-probe.ts')
+writeFileSync(probe, 'setInterval(() => {}, 1000)\n')
+afterAll(() => rmSync(fixture, { recursive: true, force: true }))
+
+// Real process discovery, without starting Next, binding ports or touching .next.
+// Trailing arguments reproduce the command signature inspected by the guard.
+async function withServer(
+ directory: string,
+ check: () => Promise,
+ args: string[] = [],
+ command: 'dev' | 'start' = 'dev'
+) {
+ const child = Bun.spawn([process.execPath, probe, 'next', command, ...args], {
+ cwd: directory,
+ stdout: 'ignore',
+ stderr: 'ignore'
+ })
+ try {
+ await check()
+ } finally {
+ child.kill()
+ await child.exited
+ }
+}
+
+test('blocks a server using this checkout before its output can be rebuilt', async () => {
+ await withServer(checkout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('using this checkout')
+ })
+})
+
+test('allows a running server in another clone with a shared path prefix', async () => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toBeNull()
+ })
+})
+
+test('allows an unrelated production server with a keep-alive timeout', async () => {
+ await withServer(
+ otherCheckout,
+ async () => {
+ expect(await nextServerConflict(checkout)).toBeNull()
+ },
+ ['--keepAliveTimeout', '60000'],
+ 'start'
+ )
+})
+
+test('blocks a relative checkout target after a keep-alive timeout', async () => {
+ await withServer(
+ otherCheckout,
+ async () => {
+ expect(await nextServerConflict(checkout)).toContain('targets this checkout')
+ },
+ ['--keepAliveTimeout', '60000', '../repo'],
+ 'start'
+ )
+})
+
+test.each([
+ ['--inspect', '127.0.0.1:9229'],
+ ['--internal-trace', 'overview']
+])('allows an unrelated server with the optional %s value', async (option, value) => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toBeNull()
+ }, [option, value])
+})
+
+test.each(['--inspect', '--internal-trace'])(
+ 'keeps resolving checkout targets when %s has no value',
+ async (option) => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('targets this checkout')
+ }, [option, '--', '../repo'])
+ }
+)
+
+test('blocks a server launched from an app subdirectory', async () => {
+ const app = join(checkout, 'apps', 'webapp')
+ mkdirSync(app, { recursive: true })
+ await withServer(app, async () => {
+ expect(await nextServerConflict(checkout)).toContain('using this checkout')
+ })
+})
+
+test('blocks an explicit app path launched from a different checkout', async () => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('references this checkout')
+ }, [checkout])
+})
+
+test('blocks a relative app path launched from another checkout', async () => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('targets this checkout')
+ }, ['-p', '3211', '../repo/apps/webapp'])
+})
+
+test('fails closed when the observed app path cannot be resolved', async () => {
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('Cannot resolve the app directory')
+ }, ['../missing-app'])
+})
+
+test('blocks an app reached through an absolute symlink alias', async () => {
+ const alias = join(fixture, 'alias')
+ symlinkSync(checkout, alias)
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('targets this checkout')
+ }, [alias])
+})
+
+test('resolves dash-prefixed app directories after the option delimiter', async () => {
+ symlinkSync(checkout, join(otherCheckout, '-app'))
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('targets this checkout')
+ }, ['--', '-app'])
+})
+
+test('fails closed on multiple positional app paths in the process listing', async () => {
+ mkdirSync(join(otherCheckout, 'first'))
+ mkdirSync(join(otherCheckout, 'second'))
+ await withServer(otherCheckout, async () => {
+ expect(await nextServerConflict(checkout)).toContain('Cannot resolve ambiguous app arguments')
+ }, ['first', 'second'])
+})
diff --git a/scripts/check-next-server.ts b/scripts/check-next-server.ts
new file mode 100644
index 000000000..5f90ffd0e
--- /dev/null
+++ b/scripts/check-next-server.ts
@@ -0,0 +1,110 @@
+#!/usr/bin/env bun
+/** Protect this checkout's .next output while allowing servers in other clones. */
+import { $ } from 'bun'
+import { realpathSync } from 'fs'
+import { resolve, sep } from 'path'
+
+async function processDirectory(pid: number): Promise {
+ try {
+ if (process.platform === 'linux') return realpathSync(`/proc/${pid}/cwd`)
+ const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
+ const directory = result.stdout
+ .toString()
+ .split('\n')
+ .find((line) => line.startsWith('n'))
+ return directory ? realpathSync(directory.slice(1)) : null
+ } catch {
+ // The process may exit between ps and this lookup. The caller verifies that case.
+ return null
+ }
+}
+
+export async function nextServerConflict(
+ root = resolve(import.meta.dir, '..')
+): Promise {
+ const canonicalRoot = realpathSync(root)
+ const processes = await $`ps -ax -o pid=,command=`.quiet().nothrow()
+ if (processes.exitCode !== 0) return 'Cannot inspect running Next servers safely'
+
+ for (const line of processes.stdout.toString().split('\n')) {
+ const match = line.trim().match(/^(\d+)\s+(.+)$/)
+ if (!match) continue
+ const launch = match[2].match(/\bnext\s+(dev|start)\b(.*)$/)
+ if (!launch) continue
+ const pid = Number(match[1])
+ const directory = await processDirectory(pid)
+ if (directory === canonicalRoot || directory?.startsWith(`${canonicalRoot}${sep}`)) {
+ return `Next server ${pid} is using this checkout; stop it before building its .next output`
+ }
+ // Also cover `next dev /absolute/app/path` launched from another directory.
+ if (
+ [root, canonicalRoot].some((path) => {
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ return new RegExp(`${escaped}(?:[/\\s"']|$)`).test(match[2])
+ })
+ ) {
+ return `Next server ${pid} references this checkout; stop it before building its .next output`
+ }
+ if (!directory) {
+ const alive = await $`ps -p ${pid} -o pid=`.quiet().nothrow()
+ if (alive.exitCode === 0) return `Cannot determine the checkout used by Next server ${pid}`
+ continue
+ }
+ // Next accepts a positional app directory relative to the launcher's cwd.
+ // ps renders argv as text; unknown/ambiguous positional paths fail closed.
+ const args = launch[2].match(/"[^"]*"|'[^']*'|\S+/g) ?? []
+ const optionsWithValue = new Set([
+ '-p',
+ '--port',
+ '-H',
+ '--hostname',
+ '--keepAliveTimeout',
+ '--experimental-https-key',
+ '--experimental-https-cert',
+ '--experimental-https-ca',
+ '--experimental-upload-trace'
+ ])
+ const optionsWithOptionalValue = new Set(['--inspect', '--internal-trace'])
+ let positionalOnly = false
+ const directories: string[] = []
+ for (let index = 0; index < args.length; index++) {
+ const argument = args[index].replace(/^(["'])(.*)\1$/, '$2')
+ if (!positionalOnly && argument === '--') {
+ positionalOnly = true
+ continue
+ }
+ if (!positionalOnly && optionsWithValue.has(argument)) {
+ index++
+ continue
+ }
+ if (!positionalOnly && optionsWithOptionalValue.has(argument)) {
+ if (args[index + 1] && !args[index + 1].startsWith('-')) index++
+ continue
+ }
+ if (!positionalOnly && argument.startsWith('-')) continue
+ directories.push(argument)
+ }
+ if (directories.length > 1)
+ return `Cannot resolve ambiguous app arguments for Next server ${pid}`
+ for (const argument of directories) {
+ let appDirectory: string
+ try {
+ appDirectory = realpathSync(resolve(directory, argument))
+ } catch {
+ return `Cannot resolve the app directory used by Next server ${pid}`
+ }
+ if (appDirectory === canonicalRoot || appDirectory.startsWith(`${canonicalRoot}${sep}`)) {
+ return `Next server ${pid} targets this checkout; stop it before building its .next output`
+ }
+ }
+ }
+ return null
+}
+
+if (import.meta.main) {
+ const conflict = await nextServerConflict()
+ if (conflict) {
+ console.error(conflict)
+ process.exit(1)
+ }
+}
diff --git a/scripts/hooks/pre-push.sh b/scripts/hooks/pre-push.sh
index 05a14934f..820d69600 100755
--- a/scripts/hooks/pre-push.sh
+++ b/scripts/hooks/pre-push.sh
@@ -11,9 +11,9 @@ fi
# check:ci skips webapp build:ci when a Next development server is live.
# A green skip is not the GitHub job. A live server plus a production
-# build also corrupts .next.
-if ps -ax -o command= | grep -E '[n]ext (dev|start)' >/dev/null; then
- echo "❌ A Next development server is running."
+# build in the same checkout also corrupts .next. Other clones have separate output.
+if ! bun "$REPO_ROOT/scripts/check-next-server.ts"; then
+ echo "❌ Cannot safely build this checkout while its Next server is running."
echo " Stop the webapp development server, then push again."
echo " After the push: restart the development server."
echo " check:ci already removes the production .next after build:ci."
diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh
index 06048ac14..7eb73f165 100755
--- a/scripts/run-tests.sh
+++ b/scripts/run-tests.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Unit + clean-room + webapp E2E. Gate order: scripts/publishable-extensions.ts.
-# Usage: bash scripts/run-tests.sh [--unit | --extensions | --e2e | all]
+# Usage: bash scripts/run-tests.sh [--unit | --extensions | --e2e | all] [--scope ]
# Needs bash (process substitution). E2E needs make dev-local. Report → Notes/.
set -o pipefail
@@ -44,11 +44,21 @@ case "${1:-all}" in
all|"") RUN_EXTENSION_GATES=true; RUN_WEBAPP_UNIT=true; RUN_E2E=true ;;
*)
echo -e "${RED}Unknown option: $1${NC}"
- echo "Usage: $0 [--unit | --extensions | --e2e | all]"
+ echo "Usage: $0 [--unit | --extensions | --e2e | all] [--scope ]"
exit 1
;;
esac
+E2E_SPEC_ROOT="$WEBAPP_DIR/cypress/e2e"
+if [ "$#" -gt 1 ]; then
+ if [ "$1" != "--e2e" ] || [ "$#" -ne 3 ] || [ "$2" != "--scope" ] || \
+ [[ ! "$3" =~ ^[a-zA-Z0-9_-]+$ ]] || [ ! -d "$E2E_SPEC_ROOT/$3" ]; then
+ echo "Usage: $0 --e2e [--scope ]"
+ exit 1
+ fi
+ E2E_SPEC_ROOT="$E2E_SPEC_ROOT/$3"
+fi
+
# One extension's gate command. The caller owns cwd, logging and exit handling,
# so the serial and parallel paths cannot drift apart.
extension_gate_cmd() {
@@ -89,6 +99,10 @@ if $RUN_E2E; then
echo -e "${YELLOW} Start it with: make dev-local${NC}"
echo -e "${YELLOW} Or set BASE_URL env var if running elsewhere.${NC}"
echo ""
+ if [ "${CI:-}" = "true" ] || [ ! -t 0 ]; then
+ echo "Start the webapp before running E2E tests."
+ exit 1
+ fi
echo -n "Continue anyway? [y/N] "
read -r answer
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
@@ -262,6 +276,7 @@ if $RUN_E2E; then
echo " E2E TESTS (Cypress)"
echo " Started: $(date)"
echo " Base URL: ${BASE_URL}"
+ echo " Spec root: ${E2E_SPEC_ROOT}"
echo " Parallel workers: ${CYPRESS_PARALLEL}"
echo "============================================================================="
echo ""
@@ -291,11 +306,12 @@ if $RUN_E2E; then
# the `cypress/e2e/...` keys the write-back below stores.
( cd "$WEBAPP_DIR" && \
SPLIT="$CYPRESS_PARALLEL" SPLIT_INDEX="$i" SPLIT_FILE="cypress/timings.json" \
+ SPLIT_OUTPUT_FILE="$WORKER_LOGS_DIR/worker-${i}-timings.json" \
bunx cypress run \
--project "$WEBAPP_DIR" \
--browser electron \
--config "baseUrl=${BASE_URL}" \
- --spec "$WEBAPP_DIR/cypress/e2e/**/*.cy.{js,ts}" \
+ --spec "$E2E_SPEC_ROOT/**/*.cy.{js,ts}" \
) > "$WORKER_LOGS_DIR/worker-${i}.log" 2>&1 &
WORKER_PIDS+=($!)
WORKER_EXITS+=(-)
@@ -443,12 +459,12 @@ if $RUN_E2E; then
# A narrower spec glob drops files silently; totals then hide the shortfall.
# Editor-only globbing hid 10 specs for two months — compare against the tree.
# Skip `manual-browser-test` to match excludeSpecPattern in cypress.config.ts.
- DISCOVERED_SPECS=$(find "$WEBAPP_DIR/cypress/e2e" \
+ DISCOVERED_SPECS=$(find "$E2E_SPEC_ROOT" \
\( -name '*.cy.js' -o -name '*.cy.ts' \) -type f \
-not -path '*manual-browser-test*' 2>/dev/null | wc -l | tr -d ' ')
if [ "$DISCOVERED_SPECS" -eq 0 ]; then
- echo -e " ${RED}✗ Found no spec files under ${WEBAPP_DIR}/cypress/e2e${NC}"
- echo " E2E found no spec files under ${WEBAPP_DIR}/cypress/e2e" >> "$REPORT"
+ echo -e " ${RED}✗ Found no spec files under ${E2E_SPEC_ROOT}${NC}"
+ echo " E2E found no spec files under ${E2E_SPEC_ROOT}" >> "$REPORT"
E2E_EXIT=1
echo ""
elif [ "$TOTAL_SPECS" -lt "$DISCOVERED_SPECS" ]; then
@@ -546,7 +562,8 @@ if $RUN_E2E; then
done
echo '' >> "$TIMINGS_TMP"
echo ']}' >> "$TIMINGS_TMP"
- if [ "$(wc -l < "$TIMINGS_TMP")" -gt 3 ]; then
+ # A scoped run must not replace timings for the rest of the webapp suite.
+ if [ "$E2E_SPEC_ROOT" = "$WEBAPP_DIR/cypress/e2e" ] && [ "$(wc -l < "$TIMINGS_TMP")" -gt 3 ]; then
mv "$TIMINGS_TMP" "$TIMINGS_FILE"
echo -e " ${DIM}Updated timings: ${TIMINGS_FILE}${NC}"
else
diff --git a/scripts/test-chatroom.sh b/scripts/test-chatroom.sh
new file mode 100644
index 000000000..eca19c472
--- /dev/null
+++ b/scripts/test-chatroom.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Isolated browser contract tests. The Cypress fixtures own all Supabase HTTP data.
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT_DIR"
+
+if [ -z "${NEXT_PUBLIC_VIRTUOSO_LICENSE:-}" ]; then
+ echo "Set NEXT_PUBLIC_VIRTUOSO_LICENSE to a valid Virtuoso Message List license."
+ exit 1
+fi
+
+export TZ=UTC
+export NODE_ENV=production
+export NEXT_PUBLIC_E2E=true
+export NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
+export NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-key
+export NEXT_PUBLIC_RESTAPI_URL=http://localhost:4000
+export NEXT_PUBLIC_PROVIDER_URL=ws://localhost:1234
+export CHATROOM_PORT="${CHATROOM_PORT:-3211}"
+export BASE_URL="http://127.0.0.1:${CHATROOM_PORT}"
+export CYPRESS_PARALLEL=1
+export CI=true
+
+# Never accidentally run against a developer's existing server.
+node -e '
+ const net = require("node:net")
+ const port = Number(process.env.CHATROOM_PORT)
+ if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1)
+ const server = net.createServer()
+ server.on("error", (error) => { console.error(error.message); process.exit(1) })
+ server.listen(port, "127.0.0.1", () => server.close())
+'
+
+mkdir -p Notes
+bash scripts/build-extensions.sh
+bun run --filter @docs.plus/webapp build:ci
+
+# Next's standalone output does not copy public or static assets automatically.
+STANDALONE_DIR="$ROOT_DIR/apps/webapp/.next/standalone/apps/webapp"
+mkdir -p "$STANDALONE_DIR/public" "$STANDALONE_DIR/.next/static"
+cp -R "$ROOT_DIR/apps/webapp/public/." "$STANDALONE_DIR/public/"
+cp -R "$ROOT_DIR/apps/webapp/.next/static/." "$STANDALONE_DIR/.next/static/"
+(
+ cd "$STANDALONE_DIR"
+ exec env PORT="$CHATROOM_PORT" HOSTNAME=127.0.0.1 node server.js
+) > "$ROOT_DIR/Notes/chatroom-server.log" 2>&1 &
+SERVER_PID=$!
+trap 'kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true' EXIT
+
+READY=false
+for attempt in $(seq 1 60); do
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
+ cat Notes/chatroom-server.log
+ exit 1
+ fi
+ if curl -fsS --connect-timeout 2 --max-time 3 "$BASE_URL/api/health" > /dev/null; then
+ READY=true
+ break
+ fi
+ sleep 1
+done
+if [ "$READY" != true ]; then
+ cat Notes/chatroom-server.log
+ echo "The chatroom test server did not become ready."
+ exit 1
+fi
+
+bash scripts/run-tests.sh --e2e --scope chatroom