Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
extends: [spectral:oas]
rules:
require-x-smoke-test-marker:
description: info object must contain a custom x-smoke-test-marker field
message: "{{description}}"
severity: error
given: $.info
then:
field: x-smoke-test-marker
function: truthy
Comment on lines +1 to +10
Copy link
Copy Markdown
Contributor

@fiosman fiosman Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, should we also include a fixture with disallowed top level keys (e.g. custom functions). In the smoke test we can make assertion that an error pops up when linting the spec. We just need 1 test to check the modal pops up etc.

This is totally OK to leave out; technically it's an edge case that I think I am already covering in a unit test for a validation function I wrote.

53 changes: 53 additions & 0 deletions packages/insomnia-smoke-test/playwright/launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ElectronApplication, PlaywrightWorkerArgs } from '@playwright/test';

import { bundleType, cwd, executablePath, mainPath } from './paths';

export interface EnvOptions {
INSOMNIA_DATA_PATH: string;
INSOMNIA_API_URL: string;
INSOMNIA_APP_WEBSITE_URL: string;
INSOMNIA_AI_URL: string;
INSOMNIA_MOCK_API_URL: string;
INSOMNIA_GITHUB_REST_API_URL: string;
INSOMNIA_GITHUB_API_URL: string;
INSOMNIA_GITLAB_API_URL: string;
INSOMNIA_UPDATES_URL: string;
INSOMNIA_SKIP_ONBOARDING: string;
INSOMNIA_PUBLIC_KEY: string;
INSOMNIA_SECRET_KEY: string;
INSOMNIA_SESSION?: string;
INSOMNIA_VAULT_KEY: string;
INSOMNIA_VAULT_SALT: string;
INSOMNIA_VAULT_SRP_SECRET: string;
KONNECT_API_URL: string;
}

/**
* Tracks every ElectronApplication launched during a test so the `app` fixture
* teardown can close any that survive (e.g. instances created by relaunch()).
*/
export const liveApps = new Set<ElectronApplication>();

/**
* Launches Insomnia with the given env options. Extracted from the `app` fixture
* so tests can perform a real process-level relaunch (see InsomniaApp.relaunch).
*/
export async function launchInsomnia(
playwright: PlaywrightWorkerArgs['playwright'],
envOptions: EnvOptions,
): Promise<ElectronApplication> {
const { ELECTRON_RUN_AS_NODE: _ignored, ...launchEnv } = process.env;
const app = await playwright._electron.launch({
cwd,
executablePath,
args: bundleType() === 'package' ? ['--no-sandbox'] : ['--no-sandbox', mainPath],
env: {
...launchEnv,
...envOptions,
PLAYWRIGHT: 'true',
},
});
liveApps.add(app);
app.on('close', () => liveApps.delete(app));
return app;
}
147 changes: 126 additions & 21 deletions packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import type { ElectronApplication, Page } from '@playwright/test';

import { launchInsomnia } from '../launch';
import { ExportModal } from './components/export-modal';
import { NavigationSidebar } from './components/navigation-sidebar';
import { StatusbarComponent } from './components/statusbar';
import { PreferencesPage } from './preferences';
import { ProjectPage } from './project';
import { WorkspacePage } from './workspace';

/**
* `ElectronApplication` with the launch-env metadata stashed by the `app`
* fixture. Named here to avoid the duplicated intersection cast that used to
* appear inside `relaunch()` and `launchClone()`.
*/
type StashedApp = ElectronApplication & {
__launchEnv?: Record<string, string>;
__playwright?: any;
};

/** Attach launch-env metadata to an `ElectronApplication` instance. */
function stashLaunchEnv(app: ElectronApplication, env: Record<string, string>, playwright: any) {
const s = app as StashedApp;
s.__launchEnv = env;
s.__playwright = playwright;
}

/**
* Root facade for the Insomnia E2E Page Object Model.
*
Expand Down Expand Up @@ -42,40 +60,67 @@ export class InsomniaApp {
// ===========================================================================

/** Statusbar (footer) — always visible. */
readonly statusbar: StatusbarComponent;
statusbar!: StatusbarComponent;

// global export modal
readonly exportModal: ExportModal;
exportModal!: ExportModal;

/** Project navigation sidebar — always visible (except login). */
readonly navigationSidebar: NavigationSidebar;
navigationSidebar!: NavigationSidebar;

// ===========================================================================
// Page objects
// ===========================================================================

/** Project page (project/file list). */
readonly projectPage: ProjectPage;
projectPage!: ProjectPage;

/** Workspace page (debug view). */
readonly workspacePage: WorkspacePage;
workspacePage!: WorkspacePage;

/** Preferences page (settings modal). */
readonly preferencesPage: PreferencesPage;

constructor(
readonly page: Page,
readonly app: ElectronApplication,
) {
// Shared components
this.statusbar = new StatusbarComponent(page);
this.exportModal = new ExportModal(page);
this.navigationSidebar = new NavigationSidebar(page);

// Pages
this.projectPage = new ProjectPage(page, app);
this.workspacePage = new WorkspacePage(page, app);
this.preferencesPage = new PreferencesPage(page, app);
preferencesPage!: PreferencesPage;

// Private backing fields exposed as readonly getters so that external callers
// cannot reassign them, while `relaunch()` can update them after a relaunch.
private _page: Page;
private _app: ElectronApplication;

get page(): Page {
return this._page;
}

get app(): ElectronApplication {
return this._app;
}

constructor(page: Page, app: ElectronApplication) {
this._page = page;
this._app = app;
this._initPageObjects();
}

private _initPageObjects() {
this.statusbar = new StatusbarComponent(this._page);
this.exportModal = new ExportModal(this._page);
this.navigationSidebar = new NavigationSidebar(this._page);
this.projectPage = new ProjectPage(this._page, this._app);
this.workspacePage = new WorkspacePage(this._page, this._app);
this.preferencesPage = new PreferencesPage(this._page, this._app);
}

/** Read the stashed launch env/playwright from the underlying Electron app, or throw. */
private _unstash(): { env: Record<string, string>; playwright: any } {
const s = this._app as StashedApp;
const env = s.__launchEnv;
const playwright = s.__playwright;
if (!env || !playwright) {
throw new Error(
'Launch env was not stashed on the ElectronApplication. ' +
'Ensure the test was started via the `app` fixture in playwright/test.ts.',
);
}
return { env, playwright };
}

// ===========================================================================
Expand All @@ -84,6 +129,66 @@ export class InsomniaApp {

/** Press Escape on the app container (closes modals, dropdowns, overlays). */
async pressEscape(): Promise<void> {
await this.page.locator('.app').press('Escape');
await this._page.locator('.app').press('Escape');
}

/**
* Queue a fake response for the next Electron `showOpenDialog` call.
* Consumed by the main-process handler when `PLAYWRIGHT === 'true'`.
*/
async queueOpenDialogResponse(filePaths: string[], canceled = false): Promise<void> {
await this._app.evaluate(
(_electron, payload) => {
const g = globalThis as any;
g.__PLAYWRIGHT_OPEN_DIALOG_QUEUE__ ||= [];
g.__PLAYWRIGHT_OPEN_DIALOG_QUEUE__.push(payload);
},
{ filePaths, canceled },
);
}

/**
* Close the current Electron process and relaunch it reusing the same env
* vars (including INSOMNIA_DATA_PATH) so on-disk state — NeDB, secret store —
* is preserved across the cycle. After this returns, `this.app` and
* `this.page` point at the fresh process; all page objects are rebuilt.
*
* The launch env is stashed on the app instance by the `app` fixture in
* `playwright/test.ts`; callers don't need to pass anything.
*/
async relaunch(): Promise<void> {
const { env, playwright } = this._unstash();
await this._app.close();

const next = await launchInsomnia(playwright, env as any);
stashLaunchEnv(next, env, playwright);

this._app = next;
this._page = await next.firstWindow({ timeout: 60_000 });
await this._page.waitForLoadState();
// Re-seed the konnect PAT like the page fixture does.
await this._page.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test'));

this._initPageObjects();
}

/**
* Launch a second Electron instance with a fresh data path and optional env
* overrides (e.g. a different INSOMNIA_SESSION for a different user). The
* returned InsomniaApp is independent — it has its own page and app references
* and will be cleaned up by the `app` fixture's liveApps teardown.
*/
async launchClone(newDataPath: string, envOverrides: Record<string, string> = {}): Promise<InsomniaApp> {
const { env, playwright } = this._unstash();
const cloneEnv = { ...env, INSOMNIA_DATA_PATH: newDataPath, ...envOverrides };

const next = await launchInsomnia(playwright, cloneEnv as any);
stashLaunchEnv(next, cloneEnv, playwright);

const page = await next.firstWindow({ timeout: 60_000 });
await page.waitForLoadState();
await page.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test'));

return new InsomniaApp(page, next);
}
}
75 changes: 35 additions & 40 deletions packages/insomnia-smoke-test/playwright/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
// Read more about creating fixtures https://playwright.dev/docs/test-fixtures
import path from 'node:path';

import type { ElectronApplication, TraceMode } from '@playwright/test';
import type { ElectronApplication, PlaywrightWorkerArgs, TraceMode } from '@playwright/test';
import { test as baseTest } from '@playwright/test';

import type { EnvOptions } from './launch';
import { launchInsomnia, liveApps } from './launch';
import { InsomniaApp } from './pages';
import { bundleType, cwd, executablePath, mainPath, randomDataPath } from './paths';
import { randomDataPath } from './paths';

// Throw an error if the condition fails
// > Not providing an inline default argument for message as the result is smaller
Expand All @@ -24,26 +26,6 @@ export function invariant(
throw new Error(typeof message === 'function' ? message() : message);
}

interface EnvOptions {
INSOMNIA_DATA_PATH: string;
INSOMNIA_API_URL: string;
INSOMNIA_APP_WEBSITE_URL: string;
INSOMNIA_AI_URL: string;
INSOMNIA_MOCK_API_URL: string;
INSOMNIA_GITHUB_REST_API_URL: string;
INSOMNIA_GITHUB_API_URL: string;
INSOMNIA_GITLAB_API_URL: string;
INSOMNIA_UPDATES_URL: string;
INSOMNIA_SKIP_ONBOARDING: string;
INSOMNIA_PUBLIC_KEY: string;
INSOMNIA_SECRET_KEY: string;
INSOMNIA_SESSION?: string;
INSOMNIA_VAULT_KEY: string;
INSOMNIA_VAULT_SALT: string;
INSOMNIA_VAULT_SRP_SECRET: string;
KONNECT_API_URL: string;
}

interface AESMessage {
iv: string;
t: string;
Expand Down Expand Up @@ -99,18 +81,16 @@ export const test = baseTest.extend<{
KONNECT_API_URL: echoServer,
...(userConfig.session ? { INSOMNIA_SESSION: JSON.stringify(userConfig.session) } : {}),
};
const { ELECTRON_RUN_AS_NODE: _ignored, ...launchEnv } = process.env;

const electronApp = await playwright._electron.launch({
cwd,
executablePath,
args: bundleType() === 'package' ? ['--no-sandbox'] : ['--no-sandbox', mainPath],
env: {
...launchEnv,
...options,
PLAYWRIGHT: 'true',
},
});

const electronApp = await launchInsomnia(playwright, options);
// Stash the launch options on the app so InsomniaApp.relaunch() can reuse them
// without re-deriving env from fixtures.
const stashed = electronApp as ElectronApplication & {
__launchEnv?: EnvOptions;
__playwright?: PlaywrightWorkerArgs['playwright'];
};
stashed.__launchEnv = options;
stashed.__playwright = playwright;

const appContext = electronApp.context();

Expand Down Expand Up @@ -145,14 +125,29 @@ export const test = baseTest.extend<{
// Use a different name rather than the default trace.zip to avoid overwriting the trace.
// Refer: https://github.com/microsoft/playwright/issues/35005
// Discard the trace if not needed
await (isTrace
? appContext.tracing.stop({
path: path.join(testInfo.outputDir, `trace-${testInfo.title}-${testInfo.status}.zip`),
})
: appContext.tracing.stop());
// The app may have been relaunched during the test (e.g. insomnia.relaunch()), which closes
// the original Electron process and invalidates appContext. Guard against that here.
try {
await (isTrace
? appContext.tracing.stop({
path: path.join(testInfo.outputDir, `trace-${testInfo.title}-${testInfo.status}.zip`),
})
: appContext.tracing.stop());
} catch {
// Original app was closed by relaunch(); tracing on the new app is not captured.
}
}

await electronApp.close();
// Close any apps that are still alive (e.g. relaunched copies). Snapshot
// first: close() fires the 'close' listener which calls liveApps.delete(),
// so iterating the live Set would skip un-visited entries.
for (const live of Array.from(liveApps)) {
try {
await live.close();
} catch {
// Best-effort: an already-closed app rejects; ignore.
}
}
},
page: async ({ app }, use) => {
// The plugin window is created after the main window's did-finish-load, so
Expand Down
Loading
Loading