diff --git a/AGENTS.md b/AGENTS.md index d9131e9fa62..e6cccf4ed1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,3 +266,94 @@ Open Settings (`Ctrl/Cmd+,`) → switch logging to **Debug** → reproduce → * 3. **Restart** app (and computer if needed). 4. **Reset data** — **View → Clear All Data**, or delete the config directory. 5. **Collect debug logs and heap snapshots**. + +## Cursor Cloud-specific instructions + +### Node version + +The project requires Node.js v20.15.0 (specified in `.nvmrc`). Use `nvm` to switch: + +```bash +source ~/.nvm/nvm.sh && nvm use 20.15.0 +``` + +### Running on headless Linux (Cloud VM) + +- The VM has an X server on display `:1`. Set `DISPLAY=:1` before launching Electron. +- Chrome sandbox requires root ownership: `sudo chown root:root ./node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox`. This is normally handled by `npm run linux-dev-setup` (called by `npm start` and `npm run watch`), but that script uses `sudo` which may prompt. +- To launch the built app directly: `DISPLAY=:1 npx electron dist/ --disable-dev-mode --no-sandbox` +- DBus errors in the container logs are expected and harmless (no system bus in containers). +- The "Failed to load configuration file" message on first run is normal — the app creates defaults. + +### Native modules + +The `postinstall` script runs `electron-builder install-app-deps` to rebuild native modules (registry-js, cf-prefs, etc.) for the current Electron version. If you see native module errors after `npm install`, ensure postinstall completed successfully. + +### Starting a local Mattermost server with Docker + +Server-backed E2E tests require a running Mattermost instance. Use Docker to spin one up locally: + +```bash +docker run -d \ + --name mattermost-e2e \ + -p 8065:8065 \ + --restart unless-stopped \ + mattermost/mattermost-preview:latest + +until curl -sf http://localhost:8065/api/v4/system/ping >/dev/null 2>&1; do + echo "Waiting for Mattermost to start..." + sleep 3 +done +echo "Mattermost is ready at http://localhost:8065" +``` + +On first launch, create the admin user and team via the API: + +```bash +curl -sf http://localhost:8065/api/v4/users \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@test.com","username":"admin","password":"admin","auth_service":""}' || true + +TOKEN=$(curl -sf http://localhost:8065/api/v4/users/login \ + -H 'Content-Type: application/json' \ + -d '{"login_id":"admin","password":"admin"}' \ + -D - 2>/dev/null | grep -i '^token:' | awk '{print $2}' | tr -d '\r') + +curl -sf http://localhost:8065/api/v4/teams \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"name":"e2e-team","display_name":"E2E Team","type":"O"}' || true +``` + +### Running E2E tests + +```bash +source ~/.nvm/nvm.sh && nvm use 20.15.0 +npm ci && cd e2e && npm ci && cd .. +npm run build-test + +cd e2e +export DISPLAY=:1 +export MM_TEST_SERVER_URL=http://localhost:8065 +export MM_TEST_USER_NAME=admin +export MM_TEST_PASSWORD=admin +npx playwright test --reporter=list --workers=1 +cd .. +``` + +If a run leaves Electron hanging: `killall Electron 2>/dev/null || true` + +### Fixing E2E tests + +When asked to fix E2E failures: + +1. **Start the Mattermost server** using the Docker instructions above. +2. **Read the CI logs** to identify which spec files failed. Use `gh run view --job --log-failed`. +3. **Build the test bundle**: `npm run build-test` +4. **Reproduce** each failure locally before editing. +5. **Fix the test** — see `e2e/AGENTS.md` for classification and design rules. +6. **Re-run the spec** to confirm the fix, then commit. + +### Login state propagation (common E2E flake) + +After `loginToMattermost()` completes, the desktop app's `isLoggedIn` flag must travel through a multi-hop IPC chain before the renderer enables tab-bar interactions (`#newTabButton`). The `waitForLoggedIn()` helper in `e2e/helpers/login.ts` polls the main-process `ServerManager` directly, which is more reliable than waiting for the DOM element. Use it in `beforeAll` blocks for any test that interacts with the tab bar after login. diff --git a/e2e/helpers/login.ts b/e2e/helpers/login.ts index c41d3d6bc30..903eb834e55 100644 --- a/e2e/helpers/login.ts +++ b/e2e/helpers/login.ts @@ -3,6 +3,8 @@ import type {ServerView} from './serverView'; +type ElectronApplication = Awaited>; + async function waitForAppShell(win: ServerView, timeout: number) { const results = await Promise.allSettled([ win.waitForSelector('#post_textbox', {timeout}), @@ -56,3 +58,50 @@ export async function loginToMattermost(win: ServerView): Promise { throw new Error(`loginToMattermost: login succeeded but the app shell never became ready. Current URL: ${await win.url()}`); } } + +/** + * Poll the main process until ServerManager reports isLoggedIn=true for the + * current server, then wait for the renderer to reflect it (the #newTabButton + * appearing in the main window DOM). + * + * The web app's desktopAPI.onLogin() → TAB_LOGIN_CHANGED → ServerManager.setLoggedIn + * chain can lag behind the web app shell becoming visible. Tests that interact + * with the tab bar (new tab, drag-and-drop, close tab) need isLoggedIn=true in + * the renderer before proceeding. + */ +export async function waitForLoggedIn( + electronApp: ElectronApplication, + mainWindow: import('playwright').Page, + timeout = 60_000, +): Promise { + const pollInterval = 500; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const loggedIn = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; // eslint-disable-line @typescript-eslint/no-explicit-any + if (!refs?.ServerManager) { + return false; + } + const serverId = refs.ServerManager.getCurrentServerId?.(); + if (!serverId) { + return false; + } + const server = refs.ServerManager.getServer?.(serverId); + return Boolean(server?.isLoggedIn); + }).catch(() => false); + + if (loggedIn) { + break; + } + + if (Date.now() + pollInterval > deadline) { + throw new Error( + `waitForLoggedIn: ServerManager.isLoggedIn never became true within ${timeout}ms`, + ); + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + await mainWindow.waitForSelector('#newTabButton', {timeout: Math.max(deadline - Date.now(), 5_000)}); +} diff --git a/e2e/specs/menu_bar/window_menu.test.ts b/e2e/specs/menu_bar/window_menu.test.ts index e2e485394b1..5a3af368ed4 100644 --- a/e2e/specs/menu_bar/window_menu.test.ts +++ b/e2e/specs/menu_bar/window_menu.test.ts @@ -10,7 +10,7 @@ import {waitForAppReady} from '../../helpers/appReadiness'; import {waitForLockFileRelease} from '../../helpers/cleanup'; import {buildServerMap} from '../../helpers/serverMap'; import {appDir, cmdOrCtrl, demoMattermostConfig, electronBinaryPath, writeConfigFile} from '../../helpers/config'; -import {loginToMattermost} from '../../helpers/login'; +import {loginToMattermost, waitForLoggedIn} from '../../helpers/login'; const windowMenuConfig = { ...demoMattermostConfig, @@ -333,6 +333,7 @@ test.describe('Menu/window_menu', () => { serverMap = await buildServerMap(electronApp); await loginToMattermost(getMattermostServer()); + await waitForLoggedIn(electronApp, mainWindow); await focusMainWindow(); }); diff --git a/e2e/specs/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index 99951f3c238..d9ad905dff3 100644 --- a/e2e/specs/server_management/bad_servers.test.ts +++ b/e2e/specs/server_management/bad_servers.test.ts @@ -313,14 +313,17 @@ test.describe('Bad Server Configurations', () => { }; const {app, userDataDir: badCertUserDataDir} = await launchWithConfig(testInfo, badConfig); try { - // Ensure the renderer has mounted its IPC listeners before the load failure - // fires, then reload to re-trigger the failure so it reaches the UI. await waitForRendererThenReload(app); const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await mainWindow!.waitForSelector('.ErrorView', {timeout: 30000}); - const errorView = await mainWindow!.$('.ErrorView'); + + let errorView = await mainWindow!.$('.ErrorView'); + if (!errorView) { + await waitForRendererThenReload(app); + await mainWindow!.waitForSelector('.ErrorView', {timeout: 60_000}); + errorView = await mainWindow!.$('.ErrorView'); + } expect(errorView).toBeDefined(); const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); diff --git a/e2e/specs/server_management/drag_and_drop.test.ts b/e2e/specs/server_management/drag_and_drop.test.ts index 109452c08fd..cf8e09dfcfb 100644 --- a/e2e/specs/server_management/drag_and_drop.test.ts +++ b/e2e/specs/server_management/drag_and_drop.test.ts @@ -10,7 +10,7 @@ import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../../helpers/config'; import {waitForLockFileRelease} from '../../helpers/cleanup'; -import {loginToMattermost} from '../../helpers/login'; +import {loginToMattermost, waitForLoggedIn} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; if (!process.env.MM_TEST_SERVER_URL) { @@ -198,7 +198,7 @@ test.describe('server_management/drag_and_drop', () => { mainWindow = await waitForWindow(electronApp, 'index'); const mmServer = await getMattermostServer(); await loginToMattermost(mmServer); - await mainWindow.waitForSelector('#newTabButton', {timeout: 30_000}); + await waitForLoggedIn(electronApp, mainWindow); }); test.beforeEach(async () => { diff --git a/e2e/specs/server_management/popout_windows.test.ts b/e2e/specs/server_management/popout_windows.test.ts index 4b0cb870d03..59baa9dc66a 100644 --- a/e2e/specs/server_management/popout_windows.test.ts +++ b/e2e/specs/server_management/popout_windows.test.ts @@ -9,7 +9,7 @@ import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../../helpers/config'; import {waitForLockFileRelease} from '../../helpers/cleanup'; -import {loginToMattermost} from '../../helpers/login'; +import {loginToMattermost, waitForLoggedIn} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; const config = { @@ -163,7 +163,7 @@ test.describe('server_management/popout_windows', () => { mainWindow = await waitForWindow(electronApp, 'index'); const mmServer = await getMattermostServer(); await loginToMattermost(mmServer); - await mainWindow.waitForSelector('#newTabButton', {timeout: 30_000}); + await waitForLoggedIn(electronApp, mainWindow); }); test.beforeEach(async () => { diff --git a/e2e/specs/server_management/tab_management.test.ts b/e2e/specs/server_management/tab_management.test.ts index af74fe48b4b..e376148f8d0 100644 --- a/e2e/specs/server_management/tab_management.test.ts +++ b/e2e/specs/server_management/tab_management.test.ts @@ -9,7 +9,7 @@ import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../../helpers/config'; import {waitForWindow, closeElectronApp} from '../../helpers/electronApp'; -import {loginToMattermost} from '../../helpers/login'; +import {loginToMattermost, waitForLoggedIn} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; const config = demoMattermostConfig; @@ -79,7 +79,7 @@ test.describe('server_management/tab_management', () => { mainWindow = await waitForWindow(electronApp, 'index'); const mmServer = await getMattermostServer(); await loginToMattermost(mmServer); - await mainWindow.waitForSelector('#newTabButton', {timeout: 30_000}); + await waitForLoggedIn(electronApp, mainWindow); }); test.beforeEach(async () => {