diff --git a/e2e/helpers/connect.ts b/e2e/helpers/connect.ts index 6006dc8c67004..e399634db7fc8 100644 --- a/e2e/helpers/connect.ts +++ b/e2e/helpers/connect.ts @@ -39,7 +39,7 @@ export async function launchApp(homeDir: string) { ); const executablePath = requireFromApp('electron'); - const app = await electron.launch({ + const electronApp = await electron.launch({ executablePath, args: [connectAppDir, '--insecure'], env: { @@ -51,12 +51,16 @@ export async function launchApp(homeDir: string) { }); try { - const page = await app.firstWindow(); + const page = await electronApp.firstWindow(); await page.waitForLoadState('domcontentloaded'); - return { app, page, [Symbol.asyncDispose]: async () => app.close() }; + return { + electronApp, + page, + [Symbol.asyncDispose]: async () => electronApp.close(), + }; } catch (err) { - await app.close(); + await electronApp.close(); throw err; } } @@ -104,7 +108,7 @@ export const test = base.extend<{ await login(launchedApp.page); } await use({ - electronApp: launchedApp.app, + electronApp: launchedApp.electronApp, page: launchedApp.page, appConfigPath, }); diff --git a/e2e/scripts/open-connect.ts b/e2e/scripts/open-connect.ts index ef6a975005755..b13e170c2079d 100644 --- a/e2e/scripts/open-connect.ts +++ b/e2e/scripts/open-connect.ts @@ -45,5 +45,5 @@ await login(launched.page); info('Teleport Connect opened and authenticated'); info('close the app window or press Ctrl+C to exit'); -await new Promise(resolve => launched.app.once('close', resolve)); +await new Promise(resolve => launched.electronApp.once('close', resolve)); info('Teleport Connect closed'); diff --git a/e2e/tests/connect/firstLaunch.spec.ts b/e2e/tests/connect/firstLaunch.spec.ts new file mode 100644 index 0000000000000..c4a4d7e0a4c77 --- /dev/null +++ b/e2e/tests/connect/firstLaunch.spec.ts @@ -0,0 +1,55 @@ +/** + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + expect, + initializeDataDir, + launchApp, + test, + withDefaultAppConfig, +} from '@gravitational/e2e/helpers/connect'; + +test('first launch shows usage data dialog', async () => { + await using temp = await fs.mkdtempDisposable( + path.join(os.tmpdir(), 'connect-e2e-first-launch-') + ); + // Set usageReporting.enabled to undefined so it's not stored in the config, which causes the + // usage data dialog to appear on launch. + await initializeDataDir( + temp.path, + withDefaultAppConfig({ 'usageReporting.enabled': undefined }) + ); + + await using app = await launchApp(temp.path); + const { page } = app; + + const usageDataDialog = page.getByText('Anonymous usage data'); + await expect(usageDataDialog).toBeVisible(); + await page.getByRole('button', { name: 'Decline', exact: true }).click(); + + // Assert the dialog is dismissed – without this, the "Connect a Cluster" check below would pass + // even if clicking Decline failed, since that screen is already rendered under the modal. + await expect(usageDataDialog).not.toBeVisible(); + + // After dismissing the dialog, the app should show the default "Connect a Cluster" screen. + await expect(page.getByText('Connect a Cluster')).toBeVisible(); +}); diff --git a/e2e/tests/connect/stateRestoration.spec.ts b/e2e/tests/connect/stateRestoration.spec.ts new file mode 100644 index 0000000000000..98aa21169ce98 --- /dev/null +++ b/e2e/tests/connect/stateRestoration.spec.ts @@ -0,0 +1,258 @@ +/** + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + expect, + initializeDataDir, + launchApp, + login, + test, + withDefaultAppConfig, +} from '@gravitational/e2e/helpers/connect'; + +// These tests manage the app lifecycle manually (multiple launches/closes), so they do not use the +// `app` fixture. +test.describe('state restoration from disk', () => { + let tempPath: string; + + test.beforeEach(async () => { + tempPath = await fs.mkdtemp(path.join(os.tmpdir(), 'connect-e2e-state-')); + await initializeDataDir(tempPath, withDefaultAppConfig({})); + }); + + test.afterEach(async () => { + await fs.rm(tempPath, { recursive: true, force: true }); + }); + + test('relaunch restores tabs', async () => { + // Login and create extra tabs. + { + await using app = await launchApp(tempPath); + const { page } = app; + await login(page); + + // Open a terminal tab so there are 2 tabs (cluster + terminal). + await page.getByTitle('Additional Actions').click(); + await page.getByText('Open new terminal').click(); + await expect( + page.getByRole('textbox', { name: 'Terminal input' }) + ).toBeVisible(); + } + + // Relaunch – the app should offer to restore the terminal tab. + { + await using app = await launchApp(tempPath); + const { page } = app; + await expect(page.getByText('Reopen previous session')).toBeVisible(); + await page.getByRole('button', { name: 'Reopen' }).click(); + await expect(page.getByText('Reopen previous session')).not.toBeVisible(); + + // Both tabs should be restored. + await expect( + page.locator('[role="tab"][data-doc-kind="doc.cluster"]') + ).toBeVisible(); + await expect( + page.locator('[role="tab"][data-doc-kind="doc.terminal_shell"]') + ).toBeVisible(); + } + }); + + test('missing app_state.json does not crash the app', async () => { + // Login to create state files on disk. + { + await using app = await launchApp(tempPath); + await login(app.page); + } + + // Remove app_state.json (keep tsh dir) – the app should not crash. + const appStatePath = path.join(tempPath, 'userData', 'app_state.json'); + await fs.rm(appStatePath); + + { + await using app = await launchApp(tempPath); + + // Without app_state.json, the app has no saved rootClusterUri, so no workspace is activated + // and the cluster connect panel prompts the user to pick one. + await expect( + app.page.getByText('Log in to a cluster to use Teleport Connect.') + ).toBeVisible(); + } + }); + + test('missing tsh home directory does not crash the app', async () => { + // Login to create state files on disk. + { + await using app = await launchApp(tempPath); + await login(app.page); + } + + // Remove the tsh home directory (keep app_state.json) – the app should not crash. + const tshHomePath = path.join(tempPath, 'home', '.tsh'); + await fs.rm(tshHomePath, { recursive: true }); + + { + await using app = await launchApp(tempPath); + const { page } = app; + + // With no tsh dir, no cluster can be connected. + await expect(page.getByText('Connect a Cluster')).toBeVisible(); + // app_state.json still references the old cluster, so setActiveWorkspace should show an error. + await expect( + page.getByText('Could not set cluster as active') + ).toBeVisible(); + } + }); + + test('logout clears previous tabs', async () => { + await using app = await launchApp(tempPath); + const { page } = app; + await login(page); + + // Open a terminal tab. + await page.getByTitle('Additional Actions').click(); + await page.getByText('Open new terminal').click(); + await expect( + page.getByRole('textbox', { name: 'Terminal input' }) + ).toBeVisible(); + + // Logout. + await page.getByTitle(/Open Profiles/).click(); + await page.getByTitle(/Log out/).click(); + await expect( + page.getByText('Are you sure you want to log out?') + ).toBeVisible(); + await page.getByRole('button', { name: 'Log Out', exact: true }).click(); + await expect(page.getByText('Connect a Cluster')).toBeVisible(); + + // Login to the same cluster again. + await login(page); + + // No restore dialog should appear – logout clears previous tabs. + // Only the default cluster tab should be open. + const clusterTab = page.locator( + '[role="tab"][data-doc-kind="doc.cluster"]' + ); + await expect(clusterTab).toHaveCount(1); + await expect( + page.locator('[role="tab"][data-doc-kind="doc.terminal_shell"]') + ).toHaveCount(0); + await expect(page.getByText('Reopen previous session')).not.toBeVisible(); + }); + + test('identical workspace shape does not trigger restore dialog', async () => { + // Login, then replace the default cluster tab with a new one (same shape). + { + await using app = await launchApp(tempPath); + const { page } = app; + await login(page); + + const clusterTab = page.locator( + '[role="tab"][data-doc-kind="doc.cluster"]' + ); + await expect(clusterTab).toBeVisible(); + + // Close the existing cluster tab. + await clusterTab.locator('.close').click(); + // Open a new cluster tab via the "+" button. + await page.getByTitle(/New Tab/).click(); + await expect(clusterTab).toBeVisible(); + } + + // Relaunch – no restore dialog since the workspace has the same shape. + { + await using app = await launchApp(tempPath); + const { page } = app; + + const clusterTab = page.locator( + '[role="tab"][data-doc-kind="doc.cluster"]' + ); + await expect(clusterTab).toHaveCount(1); + await expect(page.getByText('Reopen previous session')).not.toBeVisible(); + } + }); + + test('window remembers size and position after restart', async () => { + // Launch the app and resize the window. + let targetBounds: { x: number; y: number; width: number; height: number }; + { + await using app = await launchApp(tempPath); + const { page } = app; + await expect(page.getByText('Connect a Cluster')).toBeVisible(); + + // Pick bounds relative to the primary display's work area so the test works on any screen + // size and on multi-monitor setups where the primary display may be offset from (0, 0). + // WindowsManager.getWindowState restores saved bounds only when they fit entirely within + // a display. + const requestedBounds = await app.electronApp.evaluate(({ screen }) => { + const wa = screen.getPrimaryDisplay().workArea; + return { + x: wa.x + Math.floor(wa.width * 0.1), + y: wa.y + Math.floor(wa.height * 0.1), + width: Math.floor(wa.width * 0.6), + height: Math.floor(wa.height * 0.6), + }; + }); + + // Capture the initial bounds before resizing so we can verify setBounds() had an effect. + const initialBounds = await app.electronApp.evaluate( + ({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + return win.getNormalBounds(); + } + ); + + await app.electronApp.evaluate(({ BrowserWindow }, bounds) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setBounds(bounds); + }, requestedBounds); + + // Read back the accepted bounds after the window manager has applied them. The WM may adjust + // the requested rectangle (e.g. on Wayland or tiling layouts), and the app persists + // getNormalBounds(), not the requested values. + targetBounds = await app.electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + return win.getNormalBounds(); + }); + + // Verify the window actually moved. On environments where setBounds() is a no-op (e.g. + // Wayland, tiling WMs), the relaunch assertion would pass trivially without this guard. + expect(targetBounds).not.toEqual(initialBounds); + } + + // Relaunch – the window should restore to the same size & position. + { + await using app = await launchApp(tempPath); + + // Wait for the app to fully initialize before reading bounds and closing, otherwise the + // app may close before the renderer acks the initial cluster store message, causing a + // "Failed to receive message acknowledgement from the renderer" error dialog. + await expect(app.page.getByText('Connect a Cluster')).toBeVisible(); + + const bounds = await app.electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + return win.getNormalBounds(); + }); + + expect(bounds).toEqual(targetBounds); + } + }); +}); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts index 3f80718dd3a7f..adde6e1ac4e35 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts @@ -531,6 +531,58 @@ describe('setActiveWorkspace', () => { expect(workspacesService.getRootClusterUri()).toStrictEqual(clusterBar.uri); }); + + it('opens the documents-reopen dialog in the same tick as setting rootClusterUri', async () => { + const cluster = makeRootCluster(); + const testWorkspace: PersistedWorkspace = { + localClusterUri: cluster.uri, + documents: [ + { + kind: 'doc.terminal_shell', + uri: '/docs/terminal_shell_uri', + title: '/Users/alice/Documents', + }, + ], + location: '/docs/terminal_shell_uri', + }; + + const { workspacesService, modalsService } = getTestSetup({ + cluster, + persistedWorkspaces: { [cluster.uri]: testWorkspace }, + }); + + workspacesService.restorePersistedState(); + + // Queue a microtask before starting activation. If an await is ever introduced between setting + // rootClusterUri and opening the dialog, this microtask will execute before the dialog opens, + // causing the assertion below to fail. This invariant matters because e2e tests rely on React + // rendering rootClusterUri and the dialog in the same batch. + let microtaskRan = false; + queueMicrotask(() => { + microtaskRan = true; + }); + + jest + .spyOn(modalsService, 'openRegularDialog') + .mockImplementation(dialog => { + expect(dialog.kind).toEqual('documents-reopen'); + expect(microtaskRan).toBe(false); + expect(workspacesService.getRootClusterUri()).toBe(cluster.uri); + if (dialog.kind === 'documents-reopen') { + dialog.onDiscard(); + } + + return { + closeDialog: () => {}, + }; + }); + + await workspacesService.setActiveWorkspace(cluster.uri); + expect(modalsService.openRegularDialog).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'documents-reopen' }), + expect.any(AbortSignal) + ); + }); }); function getTestSetup(options: {