mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 11:52:33 -04:00
chore: add e2e tests for custom lint for OAS
This commit is contained in:
@@ -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
|
||||
52
packages/insomnia-smoke-test/playwright/launch.ts
Normal file
52
packages/insomnia-smoke-test/playwright/launch.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -7,6 +8,23 @@ 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.
|
||||
*
|
||||
@@ -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;
|
||||
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);
|
||||
// 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;
|
||||
|
||||
// Pages
|
||||
this.projectPage = new ProjectPage(page, app);
|
||||
this.workspacePage = new WorkspacePage(page, app);
|
||||
this.preferencesPage = new PreferencesPage(page, app);
|
||||
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 };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -24,25 +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;
|
||||
}
|
||||
|
||||
interface AESMessage {
|
||||
iv: string;
|
||||
t: string;
|
||||
@@ -97,18 +80,16 @@ export const test = baseTest.extend<{
|
||||
INSOMNIA_VAULT_SRP_SECRET: userConfig.vaultSrpSecret || '',
|
||||
...(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();
|
||||
|
||||
@@ -150,7 +131,16 @@ export const test = baseTest.extend<{
|
||||
: appContext.tracing.stop());
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import { json } from 'express';
|
||||
import type { FieldNode, OperationDefinitionNode } from 'graphql';
|
||||
import { parse } from 'graphql';
|
||||
import forge from 'node-forge';
|
||||
|
||||
export interface AESMessage {
|
||||
iv: string;
|
||||
@@ -13,33 +13,35 @@ export interface AESMessage {
|
||||
ad: string;
|
||||
}
|
||||
|
||||
// copy from packages/insomnia/src/account/crypt.ts
|
||||
export function encryptAESBuffer(jwkOrKey: string | JsonWebKey, buff: Buffer, additionalData = ''): AESMessage {
|
||||
const _b64UrlToHex = (s: string) => {
|
||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Node.js compatible base64 decoding using Buffer
|
||||
const decoded = Buffer.from(b64, 'base64').toString('binary');
|
||||
return forge.util.bytesToHex(decoded);
|
||||
};
|
||||
const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k || '');
|
||||
const key = forge.util.hexToBytes(rawKey);
|
||||
const iv = forge.random.getBytesSync(12);
|
||||
const cipher = forge.cipher.createCipher('AES-GCM', key);
|
||||
cipher.start({
|
||||
additionalData,
|
||||
iv,
|
||||
tagLength: 128,
|
||||
function jwkToKeyBuf(jwkOrKey: string | JsonWebKey): Buffer {
|
||||
return typeof jwkOrKey === 'string'
|
||||
? Buffer.from(jwkOrKey, 'hex')
|
||||
: Buffer.from(jwkOrKey.k || '', 'base64url');
|
||||
}
|
||||
|
||||
export function decryptAESBuffer(jwkOrKey: string | JsonWebKey, msg: AESMessage): Buffer {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', jwkToKeyBuf(jwkOrKey), Buffer.from(msg.iv, 'hex'), {
|
||||
authTagLength: 16,
|
||||
});
|
||||
// @ts-expect-error -- TSCONVERSION needs to be converted to string
|
||||
cipher.update(forge.util.createBuffer(buff));
|
||||
cipher.finish();
|
||||
decipher.setAuthTag(Buffer.from(msg.t, 'hex'));
|
||||
if (msg.ad) {
|
||||
decipher.setAAD(Buffer.from(msg.ad, 'hex'));
|
||||
}
|
||||
return Buffer.concat([decipher.update(Buffer.from(msg.d, 'hex')), decipher.final()]);
|
||||
}
|
||||
|
||||
export function encryptAESBuffer(jwkOrKey: string | JsonWebKey, buff: Buffer, additionalData = ''): AESMessage {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', jwkToKeyBuf(jwkOrKey), iv);
|
||||
if (additionalData) {
|
||||
cipher.setAAD(Buffer.from(additionalData, 'binary'));
|
||||
}
|
||||
const d = Buffer.concat([cipher.update(buff), cipher.final()]);
|
||||
return {
|
||||
iv: forge.util.bytesToHex(iv),
|
||||
// @ts-expect-error -- TSCONVERSION needs to be converted to string
|
||||
t: forge.util.bytesToHex(cipher.mode.tag),
|
||||
ad: forge.util.bytesToHex(additionalData),
|
||||
// @ts-expect-error -- TSCONVERSION needs to be converted to string
|
||||
d: forge.util.bytesToHex(cipher.output),
|
||||
iv: iv.toString('hex'),
|
||||
t: cipher.getAuthTag().toString('hex'),
|
||||
ad: additionalData ? Buffer.from(additionalData, 'binary').toString('hex') : '',
|
||||
d: d.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +82,12 @@ const cloudSyncProject = [
|
||||
rootDocumentId: 'wrk_efab8e758b97459bab2659d8fdcf8627',
|
||||
teams,
|
||||
},
|
||||
{
|
||||
id: 'proj_5145140e072d4007a30bfa6630ddae73',
|
||||
name: 'Design Project',
|
||||
rootDocumentId: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4',
|
||||
teams,
|
||||
},
|
||||
];
|
||||
|
||||
const commonSnapshotProps = {
|
||||
@@ -216,6 +224,28 @@ const projectSnapshots: Record<string, any[]> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// design project snapshots
|
||||
proj_5145140e072d4007a30bfa6630ddae73: [
|
||||
{
|
||||
...commonSnapshotProps,
|
||||
created: '2026-01-22T06:20:00.759Z',
|
||||
id: '2ce4bced4220de84704bee82b6174890ba4a89f0',
|
||||
name: 'Initial Snapshot',
|
||||
parent: '0000000000000000000000000000000000000000',
|
||||
state: [
|
||||
{
|
||||
blob: 'fadc80fb00f45096a75ee3acc10a7e4c9141cff8',
|
||||
key: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4',
|
||||
name: 'Design Project',
|
||||
},
|
||||
{
|
||||
blob: 'd3548e99123d182dd6fdcf89d01a61174c162c00',
|
||||
key: 'spc_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b5',
|
||||
name: 'Design Project.yaml',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const environmentProjectNewCommitSnapshot = [
|
||||
{
|
||||
@@ -285,11 +315,17 @@ const rawBlobs: Record<string, string> = {
|
||||
'{"_id":"mcp-req_18ee6d8bec7645ada7c4ac48d416bdb0","authentication":{},"connected":false,"created":1769408435331,"description":"","env":[],"headers":[{"name":"User-Agent","value":"insomnia/12.3.0"}],"mcpStdioAccess":false,"parentId":"wrk_efab8e758b97459bab2659d8fdcf8627","roots":[],"sslValidation":true,"subscribeResources":[],"transportType":"streamable-http","type":"McpRequest","url":""}',
|
||||
'379b74a13b742b573c16dda3ed38abde8cfdb0c3':
|
||||
'{"_id":"mcp-req_18ee6d8bec7645ada7c4ac48d416bdb0","authentication":{},"connected":false,"created":1769408435331,"description":"","env":[],"headers":[{"name":"User-Agent","value":"insomnia/12.3.0"}],"mcpStdioAccess":false,"parentId":"wrk_efab8e758b97459bab2659d8fdcf8627","roots":[],"sslValidation":true,"subscribeResources":[],"transportType":"streamable-http","type":"McpRequest","url":"http://localhost:4010/mcp"}',
|
||||
// design project blobs
|
||||
'fadc80fb00f45096a75ee3acc10a7e4c9141cff8':
|
||||
'{"_id":"wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4","created":1769408700000,"description":"","name":"Design Project","parentId":null,"scope":"design","type":"Workspace"}',
|
||||
'd3548e99123d182dd6fdcf89d01a61174c162c00':
|
||||
'{"_id":"spc_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b5","contents":"openapi: 3.0.0\ninfo:\n title: Petstore\n version: 1.0.0\npaths: {}","created":1769408700001,"fileName":"Design Project.yaml","parentId":"wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4","type":"ApiSpec"}',
|
||||
};
|
||||
const defaultBranches = [{ name: 'master' }, { name: 'develop' }];
|
||||
let deletedProjectIds: string[] = [];
|
||||
let cloudSyncApiEnabled = false;
|
||||
let remoteHasNewCommit = false;
|
||||
let multiUserMode = false;
|
||||
|
||||
const resetCloudSyncTestState = () => {
|
||||
Object.keys(newSnapshots).forEach(projectId => {
|
||||
@@ -300,6 +336,7 @@ const resetCloudSyncTestState = () => {
|
||||
});
|
||||
deletedProjectIds = [];
|
||||
remoteHasNewCommit = false;
|
||||
multiUserMode = false;
|
||||
};
|
||||
|
||||
const getSnapshotsForProject = (projectId: string) => {
|
||||
@@ -335,6 +372,12 @@ export default function setup(app: Application) {
|
||||
return res.status(200).send();
|
||||
});
|
||||
|
||||
app.post('/__test-config/cloud-sync/team-members', json(), (req, res) => {
|
||||
const { multi = false } = req.body ?? {};
|
||||
multiUserMode = !!multi;
|
||||
return res.status(200).send();
|
||||
});
|
||||
|
||||
// handling response for all graphql requests
|
||||
app.post('/graphql', json(), (req, res) => {
|
||||
if (!cloudSyncApiEnabled) {
|
||||
@@ -495,12 +538,23 @@ export default function setup(app: Application) {
|
||||
}
|
||||
|
||||
case 'teamMemberKeys': {
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
teamMemberKeys: {
|
||||
memberKeys: [
|
||||
const memberKeys = [
|
||||
{
|
||||
accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce',
|
||||
publicKey: JSON.stringify({
|
||||
alg: 'RSA-OAEP-256',
|
||||
e: 'AQAB',
|
||||
ext: true,
|
||||
key_ops: ['encrypt'],
|
||||
kty: 'RSA',
|
||||
n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ',
|
||||
}),
|
||||
autoLinked: false,
|
||||
},
|
||||
...(multiUserMode
|
||||
? [
|
||||
{
|
||||
accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce',
|
||||
accountId: 'acct_74b577e6b59d43a5a607f84b4f73e3df',
|
||||
publicKey: JSON.stringify({
|
||||
alg: 'RSA-OAEP-256',
|
||||
e: 'AQAB',
|
||||
@@ -511,8 +565,12 @@ export default function setup(app: Application) {
|
||||
}),
|
||||
autoLinked: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
teamMemberKeys: { memberKeys },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -567,7 +625,14 @@ export default function setup(app: Application) {
|
||||
case 'blobsCreate': {
|
||||
const blobs = variables.blobs || [];
|
||||
blobs.forEach((blob: { id: string; content: string }) => {
|
||||
newBlobs[blob.id] = blob.content;
|
||||
try {
|
||||
const aesMsg: AESMessage = JSON.parse(blob.content);
|
||||
const decryptedBuf = decryptAESBuffer(symmetricKey, aesMsg);
|
||||
newBlobs[blob.id] = zlib.gunzipSync(decryptedBuf).toString('utf8');
|
||||
} catch (e) {
|
||||
console.error('[mock] blobsCreate: decrypt failed for blob', blob.id, e);
|
||||
newBlobs[blob.id] = blob.content;
|
||||
}
|
||||
});
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import playwrightConfig from '../../playwright.config';
|
||||
import { launchInsomnia } from '../../playwright/launch';
|
||||
import { InsomniaApp } from '../../playwright/pages';
|
||||
import { getFixturePath, randomDataPath } from '../../playwright/paths';
|
||||
import { test } from '../../playwright/test';
|
||||
|
||||
// @ts-expect-error playwrightConfig.webServer.url must exist
|
||||
const devServerUrl: string = playwrightConfig?.webServer?.url || 'http://127.0.0.1:4010';
|
||||
|
||||
const RULESET_FIXTURE = getFixturePath('files/custom.spectral.yaml');
|
||||
const RULESET_RULE_NAME = 'require-x-smoke-test-marker';
|
||||
const GIT_LINT_PROJECT_NAME = 'Git Lint Rules Test';
|
||||
|
||||
/**
|
||||
* User B session: different identity (accountId / email / sessionId) but the
|
||||
* same RSA key pair and symmetric key as user A so the shared
|
||||
* `encryptedSymmetricKey` in the mock is still decryptable. This lets us test
|
||||
* a two-user collaboration scenario without generating a second key pair.
|
||||
*/
|
||||
const USER_B_SESSION = {
|
||||
id: 'sess_74b577e6b59d43a5a607f84b4f73e3df',
|
||||
sessionExpiry: new Date(2_147_483_647_000),
|
||||
publicKey: {
|
||||
alg: 'RSA-OAEP-256',
|
||||
e: 'AQAB',
|
||||
ext: true,
|
||||
key_ops: ['encrypt'],
|
||||
kty: 'RSA',
|
||||
n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ',
|
||||
},
|
||||
encPrivateKey: {
|
||||
iv: '3a1f2bdb8acbf15f469d57a2',
|
||||
t: '904d6b1bc0ece8e5df6fefb9efefda7c',
|
||||
d: '2a7b0c4beb773fa3e3c2158f0bfa654a88c4041184c3b1e01b4ddd2da2c647244a0d66d258b6abb6a9385251bf5d79e6b03ef35bdfafcb400547f8f88adb8bceb7020f2d873d5a74fb5fc561e7bd67cea0a37c49107bf5c96631374dc44ddb1e4a8b5688dc6560fc6143294ed92c3ad8e1696395dfdf15975aa67b9212366dbfcb31191e4f4fe3559c89a92fb1f0f1cc6cbf90d8a062307fce6e7701f6f5169d9247c56dae79b55fba1e10fde562b971ca708c9a4d87e6e9d9e890b88fa0480360420e610c4e41459570e52ae72f349eadf84fc0a68153722de3280becf8a1762e7faebe964f0ad706991c521feda3440d3e1b22f2c221a80490359879bd47c0d059ace81213c74a1e192dbebd8a80cf58c9eb1fe461a971b88d3899baf4c4ef7141623c93fb4a54758f5e1cf9ee35cd00777fa89b24e4ded57219e770de2670619c6e971935c61ae72e3276cf8db49dfa0e91c68222f02d7e0c69b399af505de7e5a90852d83e0a30934b0362db986f3aaefaaf1a96fef3e8165287a3a7f0ee1e072d9dee3aefb86194e1d877d6b34529d45a70ec4573c35a7fe27833c77c3154b0ad02187e4fcecd408bcf4b29a85a5dc358cb479140f4983fcd936141f581764669651530af97d2b7d9416aea7de67e787f3e29ae3eba6672bcd934dc1e308783aa63a4ab46d48d213cf53ad6bd8828011f5bfa3aa5ee24551c694e829b54c93b1dda6c3ddda04756d68a28bec8d044c8af4147680dc5b972d0ca74299b0ab6306b9e7b99bf0557558df120455a272145b7aa792654730f3d670b76d72408f5ce1cf5fbd453d2903fa72cf26397437854ba8abbb731a8107f6a86a01fa98edc81bb42a4c1330f779e7a0fbd1820eaed78e03e40a996e03884b707556be06fd14ee8f4035469210d1d2bb8f58285fc2ab6de3d3cc0e4e1f40c6d9d24b50dc8e2e2374a0aff52031b3736c2982133bb19dd551ce1f953f4ba02b0cf53382c15752e202c138cb42b2322df103ff17fd886dfd5f992b711673cdf16048c4bff19038138b161c2e1783b85fc7b965a91ac4795fcbfebf827940cacdeae57946863aee027df43b36612f3cb8f34dc44396e87c564bf10f5b1a9dfbd6da3d7f4f65024b0b4f8ce51d01c230840941fc4523b17eb1c2522032f410e8328239a11a15ab755c32945ce52966d5bfb4666909ed2ca04d536e4bf92091563dd44d46cbb35e53c2481400058ab3b52a0280d262551073f61db125ee280e2cc1ec0bdf9c4817824261465011e34c2296411384f7f5e16742157c5520f137631edf498aa39c7c32b107e3634cbeb70feea19a233c8bd939d665135c9f7c1bb33cb47edc58bdbbcde9b0b9eb73a46642e4639289a62638fb7813e1eeaadd105c803de8357236f33c4bcf31a876b5867591af8f165eba0b35cf0b0886af17dab35a6a39f8f576387d6ffb9e677ee46fc0f11ff069a2a068fce441ff8f4125095fad228c2bf45c788d641941ed13c0a16fffcafd7c7eff11bb7550c0b7d54eebdbd2066e3bbdb47aaee2b5f1e499726324a40015458c7de1db0abe872594d8e6802deff7ea9518bdb3a3e46f07139267fd67dc570ba8ab04c2b37ce6a34ec73b802c7052a2eef0cae1b0979322ef86395535db80cf2a9a88aa7c2e5cc28a93612a8dafe1982f741d7cec28a866f6c09dba5b99ead24c3df0ca03c6c5afae41f3d39608a8f49b0d6a0b541a159409791c25ede103eb4f79cfbd0cc9c9aa6b591755c1e9fd07b5b9e38ed85b5939e65d127256f6a4c078f8c9d655c4f072f9cbcfb2e1e17eaa83dc62aaab2a6dc3735ee76ce7a215740f795f1fbe7136c7734ae3714438015e8fc383d63775a8abddb23cbc5f906c046bb0b5b31d492a7c151b40ea82c7c966e25820641c55b343b89d6378f90de5983fa76547e9d6c634effdf019a0fd9b6d3e488a5aa94f0710d517ba4f7c1ed82f9f3072612e953e036c0ec7f3c618368362f6da6f3af76056a66aef914805cc8b628f1c11695f760b535ded9ff66727273ae7e12d67a01243d75f22fec8ed1b043122a211c923aa92ecbbe01dd0d7195c3c0e09a2a6ab3eca354963122d5a0ec16e2b2b81b0ddce6ec0a312c492a96a4fd392f1deb6a1f3318541a3f87e5c9e73ee7edd3b855910f412789e25038108e1eaae04dcfb02b4d958c00c630dc8caa87a40798ce7156d2ade882e68832d39fe8f9bce6a995249a7383013a5093c4af55c3b7232de0f2593d82c30b8dabd0784455037f25f6bb66a6d0d8f72bc7be0dee2d0a8af44bb4e143257d873268d331722c3253ea5c004e72daf04c875e2054f2b4b2bca2979fd046a1e835600045edf2f159d851a540a91a1ab8fbcb64594d21942bbaa2160535d32496ba7ce4a76c6bdeb9bb4c5cab7bed1ae26564058d0be125803d7019b83b3953c4b0cc1f8299c4edcf6a5faa4765092412d368b277689900e71fb5d47581057adaa2dd494e0f66dc1aa16f3741973b0d9ffa1728aeafab84b777394a7afae0f8eabaa6b740f1c60ca26469f0c9356ec880ad6f4dc01b99bd14d7a4bb8afc97662a9e68b0155e4cdf3caa3402819ac6ce562c8fe06edb50a31cfd7a',
|
||||
ad: '',
|
||||
},
|
||||
symmetricKey: {
|
||||
alg: 'A256GCM',
|
||||
ext: true,
|
||||
k: 'w62OJNWF4G8iWA8ZrTpModiY8dICyHI7ko1vMLb877g=',
|
||||
key_ops: ['encrypt', 'decrypt'],
|
||||
kty: 'oct',
|
||||
},
|
||||
email: 'insomnia-user-b@konghq.com',
|
||||
accountId: 'acct_74b577e6b59d43a5a607f84b4f73e3df',
|
||||
firstName: 'User',
|
||||
lastName: 'B',
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a fresh design document seeded with the Pet Store example. The Pet Store
|
||||
* does not include `info.x-smoke-test-marker`, so once our custom ruleset is
|
||||
* uploaded the rule defined in fixtures/files/custom.spectral.yaml will fire.
|
||||
*/
|
||||
async function openPetStoreDesignDoc(page: Page) {
|
||||
await page.getByRole('button', { name: 'Create document' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
|
||||
await page.getByText('Use example').click();
|
||||
await page.getByText('Pet Store').click();
|
||||
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0');
|
||||
}
|
||||
|
||||
async function uploadRuleset(insomnia: InsomniaApp, page: Page) {
|
||||
await insomnia.queueOpenDialogResponse([RULESET_FIXTURE]);
|
||||
await page.getByLabel('Upload custom ruleset').click();
|
||||
// Soft assert per ESLint rule; a failure here will surface downstream as well.
|
||||
await expect.soft(page.getByRole('button', { name: 'View selected ruleset content' })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function removeRuleset(page: Page) {
|
||||
await page.getByLabel('Remove custom ruleset').click();
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await expect.soft(page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function commitAndPush(page: Page, message: string) {
|
||||
await page.getByLabel('Git Sync').click();
|
||||
await page.getByLabel('Commit').click({ delay: 500 });
|
||||
// Stage all unstaged rows in the commit dialog (bounded to avoid an infinite loop).
|
||||
const plusIcons = page.locator('[data-icon="plus"]');
|
||||
for (let i = 0; i < 50 && (await plusIcons.count()) > 0; i++) {
|
||||
await plusIcons.first().click();
|
||||
}
|
||||
await page.getByRole('textbox', { name: 'Message' }).fill(message);
|
||||
await page.getByRole('button', { name: 'Commit and push' }).click();
|
||||
// The dialog closes on a successful push; fail the test if it stays open.
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function addAccessTokenGitCredential(insomnia: InsomniaApp) {
|
||||
await insomnia.statusbar.openPreferences();
|
||||
await insomnia.preferencesPage.switchToPreferenceTab('Credentials');
|
||||
await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential();
|
||||
await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible();
|
||||
await insomnia.preferencesPage.closePreferences();
|
||||
}
|
||||
|
||||
async function createGitDesignDocument(insomnia: InsomniaApp, page: Page, projectName: string) {
|
||||
await insomnia.navigationSidebar.selectProjectDropdownOption({
|
||||
actionName: 'Design Document',
|
||||
projectName,
|
||||
});
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Lint Test Spec');
|
||||
await page.getByRole('textbox', { name: /File name/ }).fill('lint_test_spec');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
|
||||
// Populate with Pet Store example so the lint panel renders (requires non-empty apiSpec.contents).
|
||||
await page.getByText('Use example').click();
|
||||
await page.getByText('Pet Store').click();
|
||||
await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the RepoFileWatcher mirror directory for the first GitRepository in
|
||||
* `dataPath`. Polls for up to 6 seconds because NeDB flushes to disk
|
||||
* asynchronously after the project is created.
|
||||
*/
|
||||
async function gitRepoMirrorPath(dataPath: string): Promise<string> {
|
||||
const dbPath = path.join(dataPath, 'insomnia.GitRepository.db');
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(dbPath, 'utf8');
|
||||
const repos = content
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((l: string) => JSON.parse(l))
|
||||
.filter((r: any) => !r.$$deleted);
|
||||
if (repos.length > 0) {
|
||||
return path.join(dataPath, 'version-control', 'git', repos[0]._id);
|
||||
}
|
||||
} catch {
|
||||
// file not yet written
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
throw new Error(`No GitRepository found in ${dbPath} after waiting`);
|
||||
}
|
||||
|
||||
test.describe('Custom Spectral Lint Rules', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Upload + lint + reopen (full process relaunch)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('upload custom ruleset, lint reflects it, persists after app relaunch', async ({ insomnia }) => {
|
||||
await openPetStoreDesignDoc(insomnia.page);
|
||||
|
||||
// Baseline: Pet Store should produce no lint problems under default OAS ruleset.
|
||||
await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible();
|
||||
await expect.soft(insomnia.page.getByText('No lint problems')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await uploadRuleset(insomnia, insomnia.page);
|
||||
|
||||
// Our custom rule should now fire on Pet Store.
|
||||
await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Close the Electron process and relaunch it against the same data path.
|
||||
// This exercises the full persistence boundary: NeDB on disk, main-process
|
||||
// startup, renderer init, clientLoader.
|
||||
await insomnia.relaunch();
|
||||
|
||||
// Re-navigate to the same design document after relaunch. The workspace is
|
||||
// created with the default name 'My Design Document'.
|
||||
await insomnia.page.getByLabel('My Design Document').first().click();
|
||||
|
||||
await expect
|
||||
.soft(insomnia.page.getByRole('button', { name: 'View selected ruleset content' }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Remove + reopen (full process relaunch)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remove custom ruleset reverts to default OAS, persists after app relaunch', async ({ insomnia }) => {
|
||||
await openPetStoreDesignDoc(insomnia.page);
|
||||
await uploadRuleset(insomnia, insomnia.page);
|
||||
await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await removeRuleset(insomnia.page);
|
||||
await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeHidden();
|
||||
await expect.soft(insomnia.page.getByText('No lint problems')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await insomnia.relaunch();
|
||||
await insomnia.page.getByLabel('My Design Document').first().click();
|
||||
|
||||
await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 15_000 });
|
||||
await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeHidden();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Cloud sync — upload, reload, and three sync round-trips
|
||||
//
|
||||
// The custom ruleset is stored as a `ProjectLintRuleset` doc (canSync = true)
|
||||
// parented to the project, so it rides cloud-sync push/pull with the rest of
|
||||
// the project's resources.
|
||||
//
|
||||
// Sub-tests:
|
||||
// 3a. basic upload within a synced design project (smoke: UI works)
|
||||
// 3b. same user, different machine (push → pull round-trip)
|
||||
// 3c. different users, same project (user A push → user B pull)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('within a cloud-sync project', () => {
|
||||
test.beforeAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' });
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3a. Basic upload in a cloud-sync design project
|
||||
// -------------------------------------------------------------------------
|
||||
test('upload custom ruleset in a synced design project', async ({ page, insomnia }) => {
|
||||
await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project');
|
||||
|
||||
await uploadRuleset(insomnia, page);
|
||||
|
||||
await page.reload();
|
||||
await expect.soft(page.getByRole('button', { name: 'View selected ruleset content' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3b. Same user, different machine — push/pull round-trip
|
||||
//
|
||||
// Machine A uploads the ruleset, commits, and pushes. Machine B starts
|
||||
// fresh (separate dataPath, same user session) and pulls the project; the
|
||||
// mock now stores pushed blobs and serves them back after decrypting the
|
||||
// incoming AES-GCM payload, so machine B should receive the
|
||||
// ProjectLintRuleset in its sync pull.
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe.serial('same user, different machine (push → pull round-trip)', () => {
|
||||
test.beforeAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' });
|
||||
});
|
||||
|
||||
test('machine A - upload ruleset and push to remote', async ({ page, insomnia }) => {
|
||||
await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project');
|
||||
|
||||
await uploadRuleset(insomnia, page);
|
||||
|
||||
await commitAndPush(page, 'Add custom lint ruleset');
|
||||
});
|
||||
|
||||
test('machine B - pull from fresh state and see the ruleset', async ({ page, insomnia }) => {
|
||||
await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project');
|
||||
|
||||
await expect
|
||||
.soft(page.getByRole('button', { name: 'View selected ruleset content' }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.soft(page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3c. Different users, same project
|
||||
//
|
||||
// User A uploads the ruleset and pushes. User B (different accountId,
|
||||
// same symmetric key so the project blobs are decryptable) starts on a
|
||||
// fresh machine, pulls the project, and verifies the ruleset survived the
|
||||
// sync handoff.
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe.serial('different users, same project', () => {
|
||||
test.beforeAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' });
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/team-members`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ multi: true }),
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' });
|
||||
});
|
||||
|
||||
test('user A - upload ruleset and push', async ({ page, insomnia }) => {
|
||||
await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project');
|
||||
|
||||
await uploadRuleset(insomnia, page);
|
||||
|
||||
await commitAndPush(page, 'User A: add custom lint ruleset');
|
||||
});
|
||||
|
||||
// Launch user B directly to avoid an idle user-A Electron process.
|
||||
test('user B - pull project and see user A ruleset', async ({ playwright, userConfig }) => {
|
||||
const echoServer = 'http://localhost:4010';
|
||||
const cloneEnv = {
|
||||
INSOMNIA_DATA_PATH: randomDataPath(),
|
||||
INSOMNIA_API_URL: echoServer,
|
||||
INSOMNIA_APP_WEBSITE_URL: `${echoServer}/website`,
|
||||
INSOMNIA_AI_URL: `${echoServer}/ai`,
|
||||
INSOMNIA_GITHUB_REST_API_URL: `${echoServer}/github-api/rest`,
|
||||
INSOMNIA_GITHUB_API_URL: `${echoServer}/github-api/graphql`,
|
||||
INSOMNIA_GITLAB_API_URL: `${echoServer}/gitlab-api`,
|
||||
INSOMNIA_UPDATES_URL: echoServer,
|
||||
INSOMNIA_MOCK_API_URL: 'https://mock-stage.insomnia.run',
|
||||
INSOMNIA_SKIP_ONBOARDING: 'true',
|
||||
INSOMNIA_PUBLIC_KEY: userConfig.publicKey,
|
||||
INSOMNIA_SECRET_KEY: userConfig.secretKey,
|
||||
INSOMNIA_VAULT_KEY: userConfig.vaultKey || '',
|
||||
INSOMNIA_VAULT_SALT: userConfig.vaultSalt || '',
|
||||
INSOMNIA_VAULT_SRP_SECRET: userConfig.vaultSrpSecret || '',
|
||||
INSOMNIA_SESSION: JSON.stringify(USER_B_SESSION),
|
||||
};
|
||||
const userBApp = await launchInsomnia(playwright, cloneEnv as any);
|
||||
(userBApp as any).__launchEnv = cloneEnv;
|
||||
(userBApp as any).__playwright = playwright;
|
||||
const userBPage = await userBApp.firstWindow({ timeout: 60_000 });
|
||||
await userBPage.waitForLoadState();
|
||||
await userBPage.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test'));
|
||||
const machineB = new InsomniaApp(userBPage, userBApp);
|
||||
|
||||
await machineB.navigationSidebar.fetchUnsyncedWorkspace('Design Project');
|
||||
|
||||
await expect
|
||||
.soft(machineB.page.getByRole('button', { name: 'View selected ruleset content' }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.soft(machineB.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Git project parity — bidirectional RepoFileWatcher sync
|
||||
//
|
||||
// The RepoFileWatcher mirrors NeDB ↔ .spectral.yaml in the workspace's
|
||||
// checkout directory at ${userData}/version-control/git/${repoId}/.
|
||||
//
|
||||
// 4a. DB→FS: upload via UI; file should appear on disk.
|
||||
// 4b. FS→DB: write .spectral.yaml directly; badge should appear.
|
||||
// 4c. Removal: remove via UI; file should be deleted from disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('within a git-sync project', () => {
|
||||
test.slow();
|
||||
|
||||
test.beforeEach(async ({ insomnia, request }) => {
|
||||
await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
await addAccessTokenGitCredential(insomnia);
|
||||
await insomnia.projectPage.createGitSyncProject(GIT_LINT_PROJECT_NAME);
|
||||
await createGitDesignDocument(insomnia, insomnia.page, GIT_LINT_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
});
|
||||
|
||||
test('4a. upload ruleset via UI mirrors to .spectral.yaml on disk', async ({ insomnia, dataPath }) => {
|
||||
const mirrorDir = await gitRepoMirrorPath(dataPath);
|
||||
await uploadRuleset(insomnia, insomnia.page);
|
||||
|
||||
const spectralPath = path.join(mirrorDir, '.spectral.yaml');
|
||||
await expect.poll(() => fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true);
|
||||
|
||||
const onDisk = await fs.promises.readFile(spectralPath, 'utf8');
|
||||
const fixture = await fs.promises.readFile(RULESET_FIXTURE, 'utf8');
|
||||
expect.soft(YAML.parse(onDisk)).toEqual(YAML.parse(fixture));
|
||||
});
|
||||
|
||||
test('4b. .spectral.yaml on disk syncs to UI badge', async ({ insomnia, dataPath }) => {
|
||||
await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const mirrorDir = await gitRepoMirrorPath(dataPath);
|
||||
const fixture = await fs.promises.readFile(RULESET_FIXTURE, 'utf8');
|
||||
await fs.promises.writeFile(path.join(mirrorDir, '.spectral.yaml'), fixture, 'utf8');
|
||||
|
||||
// The RepoFileWatcher debounces at 300 ms then writes to NeDB; the renderer
|
||||
// picks up the db.changes IPC event and re-renders.
|
||||
await expect
|
||||
.soft(insomnia.page.getByRole('button', { name: 'View selected ruleset content' }))
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('4c. remove ruleset via UI deletes .spectral.yaml from disk', async ({ insomnia, dataPath }) => {
|
||||
const mirrorDir = await gitRepoMirrorPath(dataPath);
|
||||
await uploadRuleset(insomnia, insomnia.page);
|
||||
|
||||
const spectralPath = path.join(mirrorDir, '.spectral.yaml');
|
||||
await expect.poll(() => fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true);
|
||||
|
||||
await removeRuleset(insomnia.page);
|
||||
await expect.poll(() => !fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -399,6 +399,16 @@ export function registerElectronHandlers() {
|
||||
});
|
||||
});
|
||||
ipcMainHandle('showOpenDialog', async (_, options: OpenDialogOptions) => {
|
||||
// Playwright test hook: consume queued responses set via `electronApp.evaluate`
|
||||
// instead of opening the native dialog. See packages/insomnia-smoke-test.
|
||||
if (process.env.PLAYWRIGHT === 'true') {
|
||||
const queue = (globalThis as any).__PLAYWRIGHT_OPEN_DIALOG_QUEUE__ as
|
||||
| { filePaths: string[]; canceled: boolean }[]
|
||||
| undefined;
|
||||
if (queue && queue.length > 0) {
|
||||
return queue.shift();
|
||||
}
|
||||
}
|
||||
const { filePaths, canceled } = await dialog.showOpenDialog(options);
|
||||
return { filePaths, canceled };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user