From 49d69875d4b1445f787469825c432cdb551be23b Mon Sep 17 00:00:00 2001 From: Alison Sabuwala Date: Fri, 29 May 2026 10:47:06 -0400 Subject: [PATCH] chore: add e2e tests for custom linting rules --- .../fixtures/files/custom.spectral.yaml | 10 + .../insomnia-smoke-test/playwright/launch.ts | 53 +++ .../playwright/pages/insomnia-app.ts | 147 +++++- .../insomnia-smoke-test/playwright/test.ts | 75 ++-- .../server/cloud-sync-api.ts | 134 ++++-- .../server/insomnia-api.ts | 24 + .../tests/smoke/custom-lint-rules.test.ts | 424 ++++++++++++++++++ packages/insomnia/src/main/ipc/electron.ts | 12 + 8 files changed, 784 insertions(+), 95 deletions(-) create mode 100644 packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml create mode 100644 packages/insomnia-smoke-test/playwright/launch.ts create mode 100644 packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts diff --git a/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml b/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml new file mode 100644 index 000000000000..7004ddb37295 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml @@ -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 diff --git a/packages/insomnia-smoke-test/playwright/launch.ts b/packages/insomnia-smoke-test/playwright/launch.ts new file mode 100644 index 000000000000..48a28797cd39 --- /dev/null +++ b/packages/insomnia-smoke-test/playwright/launch.ts @@ -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(); + +/** + * 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 { + 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; +} diff --git a/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts b/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts index 3cd951a308f5..91eb16d26bd6 100644 --- a/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts +++ b/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts @@ -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; + __playwright?: any; +}; + +/** Attach launch-env metadata to an `ElectronApplication` instance. */ +function stashLaunchEnv(app: ElectronApplication, env: Record, 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; - - 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; 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 { - 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 { + 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 { + 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 = {}): Promise { + 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); } } diff --git a/packages/insomnia-smoke-test/playwright/test.ts b/packages/insomnia-smoke-test/playwright/test.ts index c5d7f60b1ff3..1a440596d057 100644 --- a/packages/insomnia-smoke-test/playwright/test.ts +++ b/packages/insomnia-smoke-test/playwright/test.ts @@ -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,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; @@ -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(); @@ -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 diff --git a/packages/insomnia-smoke-test/server/cloud-sync-api.ts b/packages/insomnia-smoke-test/server/cloud-sync-api.ts index 69e5f113a7ba..3ce4571d85ab 100644 --- a/packages/insomnia-smoke-test/server/cloud-sync-api.ts +++ b/packages/insomnia-smoke-test/server/cloud-sync-api.ts @@ -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'), }; } @@ -83,6 +85,13 @@ const cloudSyncProject = [ teamProjectId: 'proj_org_7ef19d06-5a24-47ca-bc81-3dea011edec2', teams, }, + { + id: 'proj_5145140e072d4007a30bfa6630ddae73', + name: 'Design Project', + rootDocumentId: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4', + teamProjectId: 'proj_org_7ef19d06-5a24-47ca-bc81-3dea011edec2', + teams, + }, ]; const commonSnapshotProps = { @@ -219,6 +228,28 @@ const projectSnapshots: Record = { ], }, ], + // design project snapshots + proj_5145140e072d4007a30bfa6630ddae73: [ + { + ...commonSnapshotProps, + created: '2026-01-22T06:20:00.759Z', + id: '2ce4bced4220de84704bee82b6174890ba4a89f0', + name: 'Initial Snapshot', + parent: '0000000000000000000000000000000000000000', + state: [ + { + blob: '75bdac19931bd37e2853464f7a26ecbb79bc4fca', + key: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4', + name: 'Design Project', + }, + { + blob: '52aa4f92c8e47e955f0c3fcc2fd38d41710450ad', + key: 'spc_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b5', + name: 'Design Project.yaml', + }, + ], + }, + ], }; const environmentProjectNewCommitSnapshot = [ { @@ -288,11 +319,17 @@ const rawBlobs: Record = { '{"_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 + '75bdac19931bd37e2853464f7a26ecbb79bc4fca': + '{"_id":"wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4","created":1769408700000,"description":"","name":"Design Project","parentId":null,"scope":"design","type":"Workspace"}', + '52aa4f92c8e47e955f0c3fcc2fd38d41710450ad': + '{"_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 => { @@ -303,6 +340,7 @@ const resetCloudSyncTestState = () => { }); deletedProjectIds = []; remoteHasNewCommit = false; + multiUserMode = false; }; const getSnapshotsForProject = (projectId: string) => { @@ -338,6 +376,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) { @@ -498,12 +542,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', @@ -514,8 +569,12 @@ export default function setup(app: Application) { }), autoLinked: false, }, - ], - }, + ] + : []), + ]; + return res.status(200).json({ + data: { + teamMemberKeys: { memberKeys }, }, }); } @@ -570,7 +629,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: { diff --git a/packages/insomnia-smoke-test/server/insomnia-api.ts b/packages/insomnia-smoke-test/server/insomnia-api.ts index 86bb85a81584..f53be1f8c436 100644 --- a/packages/insomnia-smoke-test/server/insomnia-api.ts +++ b/packages/insomnia-smoke-test/server/insomnia-api.ts @@ -34,6 +34,14 @@ const projectsByOrgId = new Map( name: 'Personal Workspace', }, ], + // User B personal org — used by cloud-sync multi-user smoke tests so user B + // can resolve a personal organization and reach a project page. + 'org_74b577e6b59d43a5a607f84b4f73e3df': [ + { + id: 'proj_org_7ef19d06-5a24-47ca-bc81-3dea011edec2', + name: 'Personal Workspace', + }, + ], }), ); @@ -65,6 +73,22 @@ const organizations = [ ownerAccountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', }, }, + // User B personal organization — required by cloud-sync multi-user smoke + // tests so user B passes the personal-org invariant after login. + // Unique display_name avoids collisions with the User A org in selectors + // like `getByRole('option', { name: 'Personal workspace' })`. + { + id: 'org_74b577e6b59d43a5a607f84b4f73e3df', + name: 'b8e1a4d3c5f24b8da9e0c8f3b7a6d2e1', + display_name: 'User B Workspace', + branding: { + logo_url: '', + }, + metadata: { + organizationType: 'personal', + ownerAccountId: 'acct_74b577e6b59d43a5a607f84b4f73e3df', + }, + }, ]; let organizationFeatures = { diff --git a/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts new file mode 100644 index 000000000000..34a5eaa23f00 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts @@ -0,0 +1,424 @@ +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'; + +const webServerEntry = Array.isArray(playwrightConfig.webServer) + ? playwrightConfig.webServer[0] + : playwrightConfig.webServer; +const devServerUrl: string = webServerEntry?.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.getByRole('dialog').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 { + 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 page.getByTestId('workspace-grid').getByLabel('Design Project').click(); + await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 }); + + 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 page.getByTestId('workspace-grid').getByLabel('Design Project').click(); + await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 }); + + 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 page.getByTestId('workspace-grid').getByLabel('Design Project').click(); + await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 }); + + 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 page.getByTestId('workspace-grid').getByLabel('Design Project').click(); + await page.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 }); + + 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 }) => { + test.slow(); + 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 userBPage.getByTestId('workspace-grid').getByLabel('Design Project').click(); + await userBPage.waitForURL(/\/workspace\/.+\/spec/, { timeout: 30_000 }); + + 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); + }); + }); +}); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index eb608273d4a7..8036b9656f0b 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -1,5 +1,9 @@ import { mkdirSync } from 'node:fs'; +declare global { + var __PLAYWRIGHT_OPEN_DIALOG_QUEUE__: { filePaths: string[]; canceled: boolean }[] | undefined; +} + import type { IpcMainEvent, IpcMainInvokeEvent, @@ -428,6 +432,14 @@ 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.__PLAYWRIGHT_OPEN_DIALOG_QUEUE__; + if (queue && queue.length > 0) { + return queue.shift(); + } + } const { filePaths, canceled } = await dialog.showOpenDialog(options); return { filePaths, canceled }; });