Add e2e tests for state restoration in Connect (#64769)

* Refactor launchApp to avoid `app.app` callsites

* Add tests for state restoration

* Add test for first launch

* Preserve app_state.json for the missing-tsh restart case

* Choose window bounds relative to the current display

* Account for work area, read bounds after setting them

* Assert absence of the restore modal after re-login

* Assert that telemetry dialog is dismissed

* setActiveWorkspace: Test if rootClusterUri and documents-reopen are set in same tick

* Disable jest/no-conditional-expect

* Assert the reopen dialog closes after restoring tabs

* Verify that documents-reopen was called

* Verify setBounds works
This commit is contained in:
ravicious
2026-03-20 12:03:11 +00:00
committed by GitHub
parent dfca6bb24a
commit 57ad0fca08
5 changed files with 375 additions and 6 deletions
+9 -5
View File
@@ -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,
});
+1 -1
View File
@@ -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<void>(resolve => launched.app.once('close', resolve));
await new Promise<void>(resolve => launched.electronApp.once('close', resolve));
info('Teleport Connect closed');
+55
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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();
});
+258
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
});
});
@@ -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: {