From 6df7cae16fb4a26d0a75ea2678b30e464bec8920 Mon Sep 17 00:00:00 2001 From: hossein Date: Thu, 17 Sep 2026 18:18:07 +0330 Subject: [PATCH 1/4] test(chatroom): restore isolated browser contracts and CI runner --- .github/workflows/chatroom-e2e.yml | 44 +++++++ apps/webapp/cypress/e2e/chatroom/README.md | 49 ++++++++ .../cypress/e2e/chatroom/attachments.cy.ts | 111 +++++++----------- .../cypress/e2e/chatroom/deep-link.cy.ts | 9 +- .../e2e/chatroom/jump-to-present.cy.ts | 8 +- .../e2e/chatroom/long-press-reaction.cy.ts | 38 ++++-- .../cypress/e2e/chatroom/open-unread.cy.ts | 7 +- .../e2e/chatroom/reply-jump-in-window.cy.ts | 8 +- .../chatroom/reply-jump-out-of-window.cy.ts | 48 +++++--- .../e2e/chatroom/scroll-and-prepend.cy.ts | 55 +++++++-- .../cypress/e2e/chatroom/send-and-retry.cy.ts | 63 ++++++++-- apps/webapp/cypress/support/chatroom.ts | 2 +- .../cypress/support/chatroomFixtures.ts | 109 +++++++++++++++++ apps/webapp/next.config.js | 7 +- apps/webapp/src/pages/c/[channelId].tsx | 10 +- scripts/run-tests.sh | 31 +++-- scripts/test-chatroom.sh | 69 +++++++++++ 17 files changed, 527 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/chatroom-e2e.yml create mode 100644 apps/webapp/cypress/e2e/chatroom/README.md create mode 100644 apps/webapp/cypress/support/chatroomFixtures.ts create mode 100644 scripts/test-chatroom.sh 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..9fd4ace3b 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,61 @@ /// +import { 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('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 +64,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..a4863cc4b --- /dev/null +++ b/apps/webapp/cypress/support/chatroomFixtures.ts @@ -0,0 +1,109 @@ +/// + +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 stubChatroom() { + // Cypress clears cookies/localStorage between cases, but IndexedDB drafts persist. + 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')) + }) + ) + // 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/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 From 24f1a08ac531aaa1e41787248dc4734de6bd3b0d Mon Sep 17 00:00:00 2001 From: hossein Date: Thu, 17 Sep 2026 18:25:58 +0330 Subject: [PATCH 2/4] fix(ci): scope Next build protection to the active checkout --- scripts/check-ci.ts | 14 ++--- scripts/check-next-server.test.ts | 77 +++++++++++++++++++++++++ scripts/check-next-server.ts | 93 +++++++++++++++++++++++++++++++ scripts/hooks/pre-push.sh | 6 +- 4 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 scripts/check-next-server.test.ts create mode 100644 scripts/check-next-server.ts 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..3f3051c89 --- /dev/null +++ b/scripts/check-next-server.test.ts @@ -0,0 +1,77 @@ +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[] = []) { + const child = Bun.spawn([process.execPath, probe, 'next', 'dev', ...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('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]) +}) diff --git a/scripts/check-next-server.ts b/scripts/check-next-server.ts new file mode 100644 index 000000000..956a7d129 --- /dev/null +++ b/scripts/check-next-server.ts @@ -0,0 +1,93 @@ +#!/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', + '--experimental-https-key', + '--experimental-https-cert', + '--experimental-https-ca', + '--experimental-upload-trace' + ]) + for (let index = 0; index < args.length; index++) { + const argument = args[index].replace(/^(["'])(.*)\1$/, '$2') + if (optionsWithValue.has(argument)) { + index++ + continue + } + if (argument.startsWith('-')) continue + 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." From 1262743043610f039174f0bdc30992a7cb6fe5bd Mon Sep 17 00:00:00 2001 From: hossein Date: Thu, 17 Sep 2026 18:26:35 +0330 Subject: [PATCH 3/4] test(ci): cover ambiguous Next app launch arguments --- scripts/check-next-server.test.ts | 15 +++++++++++++++ scripts/check-next-server.ts | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/check-next-server.test.ts b/scripts/check-next-server.test.ts index 3f3051c89..e37e8b35f 100644 --- a/scripts/check-next-server.test.ts +++ b/scripts/check-next-server.test.ts @@ -75,3 +75,18 @@ test('blocks an app reached through an absolute symlink alias', 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 index 956a7d129..fd271bc2b 100644 --- a/scripts/check-next-server.ts +++ b/scripts/check-next-server.ts @@ -63,13 +63,24 @@ export async function nextServerConflict( '--experimental-https-ca', '--experimental-upload-trace' ]) + let positionalOnly = false + const directories: string[] = [] for (let index = 0; index < args.length; index++) { const argument = args[index].replace(/^(["'])(.*)\1$/, '$2') - if (optionsWithValue.has(argument)) { + if (!positionalOnly && argument === '--') { + positionalOnly = true + continue + } + if (!positionalOnly && optionsWithValue.has(argument)) { index++ continue } - if (argument.startsWith('-')) 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)) From 515e53ae78870513b7d184ee934ebab16f54f04a Mon Sep 17 00:00:00 2001 From: hossein Date: Thu, 17 Sep 2026 18:56:30 +0330 Subject: [PATCH 4/4] fix(tests): isolate chat drafts and parse Next timeout flags --- .../cypress/e2e/chatroom/send-and-retry.cy.ts | 40 ++++++++++++++- .../cypress/support/chatroomFixtures.ts | 18 +++++-- scripts/check-next-server.test.ts | 49 ++++++++++++++++++- scripts/check-next-server.ts | 6 +++ 4 files changed, 107 insertions(+), 6 deletions(-) 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 9fd4ace3b..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,6 +1,6 @@ /// -import { stubChatroom } from '../../support/chatroomFixtures' +import { clearChatroomDrafts, stubChatroom } from '../../support/chatroomFixtures' beforeEach(stubChatroom) @@ -10,6 +10,44 @@ describe('chatroom send and retry', () => { 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('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') diff --git a/apps/webapp/cypress/support/chatroomFixtures.ts b/apps/webapp/cypress/support/chatroomFixtures.ts index a4863cc4b..a250efb13 100644 --- a/apps/webapp/cypress/support/chatroomFixtures.ts +++ b/apps/webapp/cypress/support/chatroomFixtures.ts @@ -28,9 +28,16 @@ export const chatroomRows = Array.from({ length: 40 }, (_, i) => ({ reactions: {} })) -export function stubChatroom() { - // Cypress clears cookies/localStorage between cases, but IndexedDB drafts persist. - cy.window().then( +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') @@ -40,6 +47,11 @@ export function stubChatroom() { 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) => { diff --git a/scripts/check-next-server.test.ts b/scripts/check-next-server.test.ts index e37e8b35f..33c347f52 100644 --- a/scripts/check-next-server.test.ts +++ b/scripts/check-next-server.test.ts @@ -16,8 +16,13 @@ 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[] = []) { - const child = Bun.spawn([process.execPath, probe, 'next', 'dev', ...args], { +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' @@ -42,6 +47,46 @@ test('allows a running server in another clone with a shared path prefix', async }) }) +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 }) diff --git a/scripts/check-next-server.ts b/scripts/check-next-server.ts index fd271bc2b..5f90ffd0e 100644 --- a/scripts/check-next-server.ts +++ b/scripts/check-next-server.ts @@ -58,11 +58,13 @@ export async function nextServerConflict( '--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++) { @@ -75,6 +77,10 @@ export async function nextServerConflict( 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) }