diff --git a/package-lock.json b/package-lock.json index a1a864e835..4c45335c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4030,16 +4030,6 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@ravanallc/grpc-server-reflection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@ravanallc/grpc-server-reflection/-/grpc-server-reflection-0.1.6.tgz", - "integrity": "sha512-jd2GIZZSv1faOcrVX22cBKdWuaneCHKlvV5vEIv1SA6Kxwt88m60qUiGo/oH8MLxSoWh7gnuGH+bNjWhPScYqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "google-protobuf": "^3.19.1" - } - }, "node_modules/@react-aria/autocomplete": { "version": "3.0.0-rc.6", "resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz", @@ -15360,9 +15350,9 @@ } }, "node_modules/google-protobuf": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", - "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-4.0.3.tgz", + "integrity": "sha512-DI6iiRmF8fE/EzvGK5F9b8RxZeumHC2AAhi3xwOEh+GT5eRhJeW1aOtkDxn0W7cVCFQAtwK+ZDwIO1BYz+9WMA==", "license": "(BSD-3-Clause AND Apache-2.0)" }, "node_modules/gopd": { @@ -15464,12 +15454,12 @@ } }, "node_modules/grpc-reflection-js": { - "version": "0.3.0", - "resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#f806b5a0dc2092b7a6fb54dfb66c38fb58231774", + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#e0b00b8b76f5d58135abadf70a23d6c2022f05ad", "license": "MIT", "dependencies": { - "@types/google-protobuf": "^3.7.2", - "google-protobuf": "^3.12.2", + "@types/google-protobuf": "^3.15.12", + "google-protobuf": "^4.0.3", "protobufjs": "^7.2.2" }, "peerDependencies": { @@ -27014,8 +27004,8 @@ "version": "13.3.1", "license": "Apache-2.0", "devDependencies": { + "@grpc/reflection": "^1.0.4", "@playwright/test": "1.59.1", - "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", "@types/uuid": "^9.0.8", diff --git a/packages/insomnia-data/common-src/hotkeys.ts b/packages/insomnia-data/common-src/hotkeys.ts index 2f69718f37..3e764cd117 100644 --- a/packages/insomnia-data/common-src/hotkeys.ts +++ b/packages/insomnia-data/common-src/hotkeys.ts @@ -26,7 +26,6 @@ export const keyboardShortcutDescriptions: Record = { request_showGenerateCodeEditor: 'Generate Code', sidebar_focusFilter: 'Filter Sidebar', sidebar_showCreateDropdown: 'Open Create Dropdown', - sidebar_deleteItem: 'Delete Sidebar Item', sidebar_toggle: 'Toggle Sidebar', sidebar_rename: 'Rename Item', response_focus: 'Focus Response', @@ -130,10 +129,6 @@ const defaultRegistry: HotKeyRegistry = { macKeys: [{ meta: true, keyCode: keyboardKeys.n.keyCode }], winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.n.keyCode }], }, - sidebar_deleteItem: { - macKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }], - winLinuxKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }], - }, sidebar_toggle: { macKeys: [{ meta: true, keyCode: keyboardKeys.backslash.keyCode }], winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.backslash.keyCode }], diff --git a/packages/insomnia-data/common-src/settings.ts b/packages/insomnia-data/common-src/settings.ts index cccc548251..d6cb55c83d 100644 --- a/packages/insomnia-data/common-src/settings.ts +++ b/packages/insomnia-data/common-src/settings.ts @@ -47,7 +47,6 @@ export type KeyboardShortcut = | 'request_showGenerateCodeEditor' | 'sidebar_focusFilter' | 'sidebar_showCreateDropdown' - | 'sidebar_deleteItem' | 'sidebar_toggle' | 'sidebar_rename' | 'response_focus' diff --git a/packages/insomnia-smoke-test/fixtures/route_guide.bin b/packages/insomnia-smoke-test/fixtures/route_guide.bin deleted file mode 100644 index 489f4b3c5e..0000000000 Binary files a/packages/insomnia-smoke-test/fixtures/route_guide.bin and /dev/null differ diff --git a/packages/insomnia-smoke-test/fixtures/socket-io.yaml b/packages/insomnia-smoke-test/fixtures/socket-io.yaml index 3942acce98..1c561ed082 100644 --- a/packages/insomnia-smoke-test/fixtures/socket-io.yaml +++ b/packages/insomnia-smoke-test/fixtures/socket-io.yaml @@ -29,6 +29,17 @@ collection: eventName: eventname desc: this is a listener isOpen: false + body: + meta: + id: socket-io-payload_9a1c4f1b4f3b4d2e9c0a7d6e5f4b3a21 + created: 1753709130600 + modified: 1753757257862 + eventName: hello + ack: false + args: + - id: 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f + value: '"world"' + mode: application/json cookieJar: name: Default Jar meta: diff --git a/packages/insomnia-smoke-test/package.json b/packages/insomnia-smoke-test/package.json index 82167144cc..0eefadd6d1 100644 --- a/packages/insomnia-smoke-test/package.json +++ b/packages/insomnia-smoke-test/package.json @@ -20,8 +20,8 @@ "serve": "esr server/index.ts" }, "devDependencies": { + "@grpc/reflection": "^1.0.4", "@playwright/test": "1.59.1", - "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", "@types/uuid": "^9.0.8", diff --git a/packages/insomnia-smoke-test/server/grpc.ts b/packages/insomnia-smoke-test/server/grpc.ts index e7a006d5b1..5e70da21bf 100644 --- a/packages/insomnia-smoke-test/server/grpc.ts +++ b/packages/insomnia-smoke-test/server/grpc.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import * as grpc from '@grpc/grpc-js'; import type { HandleCall } from '@grpc/grpc-js/build/src/server-call'; import * as protoLoader from '@grpc/proto-loader'; -import { addReflection } from '@ravanallc/grpc-server-reflection'; +import { ReflectionService } from '@grpc/reflection'; const PROTO_PATH = path.resolve('../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, { @@ -181,9 +181,10 @@ const routeChat: HandleCall = (call: any) => { export const startGRPCServer = (port: number) => { return new Promise((resolve, reject) => { const server = new grpc.Server(); + const reflectionService = new ReflectionService(packageDefinition); // Enable reflection - addReflection(server, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + reflectionService.addToServer(server); // @ts-expect-error generated from proto file server.addService(routeguide.RouteGuide.service, { @@ -211,7 +212,7 @@ export const startGRPCServer = (port: number) => { const serverWithTLS = new grpc.Server(); // Enable reflection - addReflection(serverWithTLS, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + reflectionService.addToServer(serverWithTLS); // @ts-expect-error generated from proto file serverWithTLS.addService(routeguide.RouteGuide.service, { diff --git a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts index eeb6152922..fd1e0ac6f9 100644 --- a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts @@ -10,6 +10,7 @@ import { test } from '../../playwright/test'; // - create/rename/settings dialogs focus the Name field // - the navigation sidebar expands/collapses folders with Left/Right arrows // - Cmd/Ctrl-N adds the new request inside the selected folder +// - Delete on a focused sidebar row asks to delete it; Backspace never does // // CodeMirror-backed editors (OneLineEditor) expose focus via `data-focused="on"` on their // `.editor__container`, which is what these tests assert against. @@ -49,6 +50,22 @@ test.describe('Focus and keyboard navigation', () => { await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1); }); + test('clicking Add moves focus off the Add button so typing lands in the new Name cell', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + + await page.getByRole('tab', { name: 'Params' }).click(); + const addButton = page.getByTestId('request-pane').getByRole('button', { name: 'Add', exact: true }); + await addButton.click(); + + // The Add button keeps focus until the new editor takes it; the autofocus re-grab must not treat + // it as the user having deliberately moved to another control. + await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1); + await expect.soft(addButton).not.toBeFocused(); + + await page.keyboard.type('smoke-key'); + await expect.soft(page.locator('.editor__container:has([id^="key-value-editor__name"]) .CodeMirror-code').first()).toHaveText('smoke-key'); + }); + test('the KV environment editor focuses the blank row Name', async ({ page }) => { await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); @@ -150,6 +167,37 @@ test.describe('Focus and keyboard navigation', () => { await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible(); }); + test('Delete on a focused sidebar row opens the delete confirmation, Backspace does not', async ({ page }) => { + const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])'); + const deleteDialog = page.getByRole('dialog').filter({ hasText: 'Delete Folder' }); + + // Backspace must not delete (it's reserved for editing text), even with a row focused. + await folderRow.focus(); + await folderRow.press('Backspace'); + await expect.soft(deleteDialog).toBeHidden(); + + // Delete on the focused row asks for confirmation; cancelling keeps the folder. + await folderRow.press('Delete'); + await expect.soft(deleteDialog).toBeVisible(); + await deleteDialog.getByRole('button', { name: 'Cancel' }).click(); + await expect.soft(deleteDialog).toBeHidden(); + await expect.soft(page.getByTestId('request-node-test folder')).toBeVisible(); + + // Confirming removes it. + await folderRow.press('Delete'); + await deleteDialog.getByRole('button', { name: 'Delete' }).click(); + await expect.soft(page.getByTestId('request-node-test folder')).toBeHidden(); + }); + + test('Delete inside the sidebar filter edits text and does not open the delete confirmation', async ({ + page, + insomnia, + }) => { + await insomnia.navigationSidebar.fillFilter('test'); + await insomnia.navigationSidebar.filterInput.press('Delete'); + await expect.soft(page.getByRole('dialog')).toBeHidden(); + }); + test('Cmd/Ctrl-N adds the request inside the selected folder', async ({ page }) => { const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])'); diff --git a/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts b/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts index 5b1b03a96a..dcdeaa85ed 100644 --- a/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts @@ -20,6 +20,14 @@ test('can make socket.io connection', async ({ app, page, insomnia }) => { await insomnia.navigationSidebar.clickRequestOrFolder('Socket.IO Request'); await expect.soft(page.locator('.app')).toContainText('http://localhost:4020'); + + // The body lives in its own document, so it only survives the import if the v5 file carries it. + // Params is the initially selected tab and react-aria unmounts the panels it is not showing, + // so the Body tab has to be opened before any of it can be asserted. + await page.getByRole('tab', { name: 'Body' }).click(); + await expect.soft(page.getByLabel('Event Name')).toHaveValue('hello'); + await expect.soft(page.locator('[data-testid="CodeEditor"]:visible').first()).toContainText('world'); + await page.click('text=Connect'); await expect.soft(statusTag).toContainText('Connected', { ignoreCase: true }); await page.getByRole('tab', { name: 'Console' }).click(); diff --git a/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts b/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts index 48b1efcd37..6fb4df9df1 100644 --- a/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts +++ b/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts @@ -570,6 +570,29 @@ describe('RequestCollectionSchema (recursive union)', () => { expect(parsed).toHaveLength(5); }); + // The union is not discriminated: without a `meta.id` prefix to go on, the only thing keeping + // a Socket.IO item out of the WebSocket branch (which would strip its Socket.IO-only keys) are + // the `z.undefined()` guards. + it('keeps eventListeners on a Socket.IO item that has no meta', () => { + const item = makeSocketIORequest({ meta: undefined }); + const [parsed] = RequestCollectionSchema.parse([item]); + expect(parsed).toMatchObject({ eventListeners: [{ id: 'ev1', eventName: 'join' }] }); + }); + + // `eventListeners` is dropped so the body guard is the only thing rejecting the WebSocket + // branch here — otherwise this would pass on the strength of the eventListeners guard alone + it('keeps the body of a Socket.IO item that has no meta', () => { + const item = makeSocketIORequest({ + meta: undefined, + eventListeners: undefined, + body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] }, + }); + const [parsed] = RequestCollectionSchema.parse([item]); + expect(parsed).toMatchObject({ + body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] }, + }); + }); + it('rejects leaf nodes with forbidden props (e.g., children on Request)', () => { const bad = structuredClone(makeHttpRequest()); // @ts-expect-error - children is not allowed on Request diff --git a/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts b/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts new file mode 100644 index 0000000000..808ac62e6f --- /dev/null +++ b/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts @@ -0,0 +1,315 @@ +/** + * A Socket.IO / WebSocket request's body lives in its own document (SocketIOPayload / + * WebSocketPayload) rather than on the request, and used to be absent from the v5 format + * entirely. These tests pin it to the format: INS-3123. + */ + +import type { Workspace } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; +import { beforeEach, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; + +import { database as db } from '../database'; +import { importResourcesToNewWorkspace } from '../import'; +import { getInsomniaV5DataExport, importInsomniaV5Data } from '../insomnia-v5'; + +const WORKSPACE_ID = 'wrk_payload'; +const SOCKET_IO_REQUEST_ID = 'socketio-req_payload'; +const WEBSOCKET_REQUEST_ID = 'ws-req_payload'; + +const exportWorkspace = (workspaceId = WORKSPACE_ID) => + getInsomniaV5DataExport({ workspaceId, includePrivateEnvironments: false }); + +const findNode = (yaml: string, name: string) => + YAML.parse(yaml).collection.find((item: { name: string }) => item.name === name); + +const createSocketIORequest = (patch: Record = {}) => + services.socketIORequest.create({ + _id: SOCKET_IO_REQUEST_ID, + name: 'socketio-request', + parentId: WORKSPACE_ID, + url: 'https://socket.example.com', + metaSortKey: 1, + ...patch, + }); + +// @vitest-environment jsdom +describe('Socket.IO / WebSocket payloads in the v5 format', () => { + beforeEach(async () => { + await db.init({ inMemoryOnly: true }); + await services.project.create({ _id: 'proj_payload', name: 'Payload Project' }); + await services.workspace.create({ + _id: WORKSPACE_ID, + name: 'Payload Workspace', + parentId: 'proj_payload', + scope: 'collection', + }); + await services.environment.getOrCreateForParentId(WORKSPACE_ID); + await services.settings.getOrCreate(); + }); + + describe('export', () => { + // Opening a Socket.IO request creates an empty payload document, which must not turn into a + // `body:` block — otherwise every repo gains a spurious git diff from merely browsing + it('omits body for a payload created by only opening the request', async () => { + await createSocketIORequest(); + await services.socketIOPayload.getOrCreateByParentId(SOCKET_IO_REQUEST_ID); + + expect(findNode(await exportWorkspace(), 'socketio-request').body).toBeUndefined(); + }); + + it('omits body for an untouched WebSocket payload', async () => { + await services.webSocketRequest.create({ + _id: WEBSOCKET_REQUEST_ID, + name: 'websocket-request', + parentId: WORKSPACE_ID, + url: 'wss://echo.example.com', + metaSortKey: 2, + }); + await services.webSocketPayload.create({ parentId: WEBSOCKET_REQUEST_ID }); + + expect(findNode(await exportWorkspace(), 'websocket-request').body).toBeUndefined(); + }); + + it('emits every arg once any of them has content', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [ + { id: 'arg1', value: '"world"', mode: 'application/json' }, + { id: 'arg2', value: '', mode: 'text/plain' }, + ], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toEqual([ + { id: 'arg1', value: '"world"', mode: 'application/json' }, + { id: 'arg2', value: '', mode: 'text/plain' }, + ]); + }); + + // Arg rows and content types are configuration in their own right. Calling these untouched + // would omit the body, and the next sync's `deleteOrphans` would then delete the payload. + it('emits a body for an arg the user added but left blank', async () => { + await createSocketIORequest(); + const { args } = models.socketIOPayload.init(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [...args, { id: 'arg2', value: '', mode: args[0].mode }], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toHaveLength(2); + }); + + it('emits a body when only an arg content type changed', async () => { + await createSocketIORequest(); + const { args } = models.socketIOPayload.init(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [{ ...args[0], mode: 'text/plain' }], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toMatchObject([ + { value: '', mode: 'text/plain' }, + ]); + }); + }); + + describe('import', () => { + it('recreates the payload as a child of its request', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + _id: 'socket-io-payload_known', + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + + const resources = importInsomniaV5Data(await exportWorkspace()); + const payload = resources.find(resource => resource.type === 'SocketIOPayload'); + + expect(payload).toMatchObject({ + _id: 'socket-io-payload_known', + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + expect(payload).toHaveProperty('_type', 'socketio_payload'); + }); + + // `importResourcesToNewWorkspace` keys its id map by `_id`, so payloads sharing one literal + // placeholder would collapse into a single document. Hand-written YAML may well omit + // `body.meta`, which is what puts them on that placeholder path. + it('gives bodies without a meta id distinct payload ids', async () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: No Body Meta +meta: + id: wrk_no_body_meta +collection: + - url: https://socket.example.com + name: First + meta: + id: socketio-req_first + body: + eventName: first + args: + - id: arg1 + value: '"a"' + - url: https://socket.example.com + name: Second + meta: + id: socketio-req_second + body: + eventName: second + args: + - id: arg2 + value: '"b"' +`; + + const payloads = importInsomniaV5Data(yaml).filter(resource => resource.type === 'SocketIOPayload'); + + expect(payloads).toHaveLength(2); + expect(payloads[0]._id).not.toBe(payloads[1]._id); + expect(payloads[0].parentId).not.toBe(payloads[1].parentId); + }); + + // Git Sync writes imported documents to the database verbatim, so a generated payload id has + // to survive the export that validates it. A non-conforming one throws inside + // `InsomniaFileSchema.parse`, which empties the export — and an empty export is skipped by + // every flush site, silently stopping db-to-fs sync for the whole workspace. + it('re-exports a body that arrived without a body.meta', async () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Payload Workspace +meta: + id: ${WORKSPACE_ID} +collection: + - url: https://socket.example.com + name: socketio-request + meta: + id: ${SOCKET_IO_REQUEST_ID} + body: + eventName: chat message + args: + - id: arg1 + value: '"world"' + - url: wss://echo.example.com + name: websocket-request + meta: + id: ${WEBSOCKET_REQUEST_ID} + body: + value: '{"ping":1}' +`; + + for (const doc of importInsomniaV5Data(yaml)) { + await db.update(doc); + } + + const reExported = await exportWorkspace(); + + expect(reExported).not.toBe(''); + expect(findNode(reExported, 'socketio-request').body).toMatchObject({ eventName: 'chat message' }); + expect(findNode(reExported, 'websocket-request').body).toMatchObject({ value: '{"ping":1}' }); + }); + + // The union guards keep the two realtime types apart inside `RequestCollectionSchema`, but + // `walkCollection` routes with the base schemas, so this has to be asserted end to end + it('keeps a Socket.IO item without meta on the Socket.IO branch', () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: No Meta +meta: + id: wrk_no_meta +collection: + - url: https://socket.example.com + name: Rooms + eventListeners: + - id: ev1 + eventName: joined + body: + eventName: chat message + ack: true + args: + - id: arg1 + value: '"world"' +`; + + const resources = importInsomniaV5Data(yaml); + + expect(resources.map(resource => resource.type)).toEqual( + expect.arrayContaining(['SocketIORequest', 'SocketIOPayload']), + ); + expect(resources.map(resource => resource.type)).not.toContain('WebSocketPayload'); + expect(resources.find(resource => resource.type === 'SocketIOPayload')).toMatchObject({ + eventName: 'chat message', + ack: true, + }); + }); + }); + + // The three calls the Duplicate Collection action makes, in order + it('preserves payloads when a collection is duplicated (INS-3123)', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'hello', + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + await services.webSocketRequest.create({ + _id: WEBSOCKET_REQUEST_ID, + name: 'websocket-request', + parentId: WORKSPACE_ID, + url: 'wss://echo.example.com', + metaSortKey: 2, + }); + await services.webSocketPayload.create({ + parentId: WEBSOCKET_REQUEST_ID, + value: '{"ping":1}', + mode: 'application/json', + }); + // An untouched request, which must stay empty in the copy + await services.socketIORequest.create({ + _id: 'socketio-req_untouched', + name: 'untouched', + parentId: WORKSPACE_ID, + url: 'https://socket.example.com', + metaSortKey: 3, + }); + await services.socketIOPayload.getOrCreateByParentId('socketio-req_untouched'); + + const oldWorkspace = (await services.workspace.getById(WORKSPACE_ID)) as Workspace; + const content = await exportWorkspace(); + const data = importInsomniaV5Data(content); + + const newWorkspace = await importResourcesToNewWorkspace({ + projectId: 'proj_payload', + workspaceToImport: { ...oldWorkspace, name: 'Payload Workspace (Copy)' }, + resourceCacheItem: { + resources: data, + content: JSON.stringify(data), + importer: { id: 'insomnia-v5', name: 'Insomnia v5 Importer', description: '' }, + }, + }); + + const copiedRequests = await services.socketIORequest.findByParentId(newWorkspace._id); + const copiedSocketIO = copiedRequests.find(request => request.name === 'socketio-request'); + const [copiedWebSocket] = await services.webSocketRequest.findByParentId(newWorkspace._id); + + expect(newWorkspace._id).not.toBe(WORKSPACE_ID); + expect(copiedSocketIO?._id).not.toBe(SOCKET_IO_REQUEST_ID); + + await expect(services.socketIOPayload.getByParentId(copiedSocketIO!._id)).resolves.toMatchObject({ + eventName: 'hello', + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + await expect(services.webSocketPayload.getByParentId(copiedWebSocket._id)).resolves.toMatchObject({ + value: '{"ping":1}', + mode: 'application/json', + }); + + const copiedUntouched = copiedRequests.find(request => request.name === 'untouched'); + await expect(services.socketIOPayload.getByParentId(copiedUntouched!._id)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts index 2485f5898c..0e72939487 100644 --- a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts +++ b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts @@ -6,7 +6,7 @@ */ import type { Request } from 'insomnia-data'; -import { EnvironmentKvPairDataType, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, EnvironmentType, services } from 'insomnia-data'; import { beforeEach, describe, expect, it } from 'vitest'; import YAML from 'yaml'; @@ -400,6 +400,43 @@ spec: expect(parsed.spec).toBeDefined(); }); + // A Socket.IO request used to be dropped from a design doc's export, which in a Git Sync + // project meant `deleteOrphans` removed the request itself on the next sync + it('exports Socket.IO requests from a design workspace', async () => { + const workspace = await services.workspace.create({ + _id: 'wrk_design_socketio', + name: 'Design Workspace', + parentId: 'proj_test', + scope: 'design', + }); + await services.environment.create({ + _id: 'env_design_socketio', + name: 'Base Env', + parentId: workspace._id, + data: {}, + }); + await services.apiSpec.getOrCreateForParentId(workspace._id, { + _id: 'spec_design_socketio', + contents: '{"openapi": "3.0.0"}', + contentType: 'json', + }); + await services.socketIORequest.create({ + _id: 'socketio-req_design', + name: 'design-socketio', + parentId: workspace._id, + url: 'https://socket.example.com', + metaSortKey: 1, + }); + + const result = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + + const parsed = YAML.parse(result); + expect(parsed.collection.map((item: { name: string }) => item.name)).toContain('design-socketio'); + }); + it('handles mock server scope', async () => { const workspace = await services.workspace.create({ _id: 'wrk_mock', @@ -693,6 +730,20 @@ spec: eventListeners: [{ id: 'listener1', eventName: 'join', desc: 'on join', isOpen: true }], metaSortKey: 5, }); + await services.webSocketPayload.create({ + _id: 'ws-payload_order', + parentId: 'ws-req_order', + name: 'payload-order', + value: '{"ping":1}', + mode: 'application/json', + }); + await services.socketIOPayload.create({ + _id: 'socket-io-payload_order', + parentId: 'socketio-req_order', + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); const cookieJar = await services.cookieJar.getOrCreateForParentId(workspace._id); await services.cookieJar.update(cookieJar, { @@ -795,9 +846,13 @@ spec: 'headers', 'parameters', 'pathParameters', + 'body', ]); expect(Object.keys(exportedWebSocket.settings)).toEqual(['encodeUrl', 'cookies', 'followRedirects']); expect(Object.keys(exportedWebSocket.settings.cookies)).toEqual(['store', 'send']); + expect(Object.keys(exportedWebSocket.body)).toEqual(['meta', 'name', 'value', 'mode']); + expect(Object.keys(exportedWebSocket.body.meta)).toEqual(['id', 'created', 'modified']); + expect(exportedWebSocket.body.meta.id).toBe('ws-payload_order'); const exportedSocketIO = findItem('socketio-order'); expect(Object.keys(exportedSocketIO)).toEqual([ @@ -810,10 +865,15 @@ spec: 'parameters', 'pathParameters', 'eventListeners', + 'body', ]); expect(Object.keys(exportedSocketIO.settings)).toEqual(['encodeUrl', 'cookies', 'path']); expect(Object.keys(exportedSocketIO.settings.cookies)).toEqual(['store', 'send']); expect(Object.keys(exportedSocketIO.eventListeners[0])).toEqual(['id', 'eventName', 'desc', 'isOpen']); + expect(Object.keys(exportedSocketIO.body)).toEqual(['meta', 'eventName', 'ack', 'args']); + expect(Object.keys(exportedSocketIO.body.meta)).toEqual(['id', 'created', 'modified']); + expect(exportedSocketIO.body.meta.id).toBe('socket-io-payload_order'); + expect(Object.keys(exportedSocketIO.body.args[0])).toEqual(['id', 'value', 'mode']); expect(Object.keys(parsed.cookieJar)).toEqual(['name', 'meta', 'cookies']); expect(Object.keys(parsed.cookieJar.cookies[0])).toEqual([ @@ -836,6 +896,123 @@ spec: }); }); + describe('confidentialKeys', () => { + const kvPair = (name: string, value: string, isConfidential: boolean, enabled = true) => ({ + id: `envPair_${name}_${value}`, + name, + value, + type: EnvironmentKvPairDataType.STRING, + enabled, + isConfidential, + }); + + it('exports the names of confidential variables for environments and folders', async () => { + const baseEnvironment = await services.environment.create({ + _id: 'env_base', + name: 'Base Env', + parentId: 'wrk_test', + data: { token: 'secret', host: 'example.com', apiKey: 'key', renamed: 'public' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [ + kvPair('token', 'secret', true), + kvPair('host', 'example.com', false), + kvPair('apiKey', 'key', true), + // only the last enabled row with a name takes effect + kvPair('renamed', 'old', true), + kvPair('renamed', 'public', false), + kvPair('disabled', 'value', true, false), + ], + }); + await services.environment.create({ + _id: 'env_sub', + name: 'Sub Env', + parentId: baseEnvironment._id, + data: { password: 'p@ss' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [kvPair('password', 'p@ss', true)], + }); + await services.environment.create({ + _id: 'env_json', + name: 'JSON Env', + parentId: baseEnvironment._id, + data: { stale: 'value' }, + // kvPairData is not the source of truth in the JSON view + environmentType: EnvironmentType.JSON, + kvPairData: [kvPair('stale', 'value', true)], + }); + await services.requestGroup.create({ + _id: 'fld_confidential', + name: 'Folder', + parentId: 'wrk_test', + environment: { folderToken: 'folder-secret' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [kvPair('folderToken', 'folder-secret', true)], + }); + + const exported = YAML.parse( + await getInsomniaV5DataExport({ workspaceId: 'wrk_test', includePrivateEnvironments: false }), + ); + + expect(exported.environments.confidentialKeys).toEqual(['apiKey', 'token']); + const subEnvironments = Object.fromEntries(exported.environments.subEnvironments.map((e: any) => [e.name, e])); + expect(subEnvironments['Sub Env'].confidentialKeys).toEqual(['password']); + expect(subEnvironments['JSON Env']).not.toHaveProperty('confidentialKeys'); + expect(exported.collection[0].confidentialKeys).toEqual(['folderToken']); + }); + + it('restores the table view and confidential flags from confidentialKeys', () => { + const result = importInsomniaV5Data(` +type: collection.insomnia.rest/5.0 +name: Test Collection +meta: + id: wrk_test +collection: + - name: Folder + meta: + id: fld_confidential + environment: + folderToken: folder-secret + confidentialKeys: + - folderToken +environments: + name: Base Env + meta: + id: env_base + data: + token: secret + host: example.com + confidentialKeys: + - token + subEnvironments: + - name: Sub Env + meta: + id: env_sub + data: + password: p@ss + confidentialKeys: + - password + - name: Plain Env + meta: + id: env_plain + data: + foo: bar +`); + const byId = Object.fromEntries(result.map(doc => [doc._id, doc as any])); + const flags = (doc: any) => + Object.fromEntries(doc.kvPairData.map((pair: any) => [pair.name, pair.isConfidential])); + + expect(byId.env_base.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.env_base)).toEqual({ token: true, host: false }); + expect(byId.env_sub.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.env_sub)).toEqual({ password: true }); + expect(byId.fld_confidential.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.fld_confidential)).toEqual({ folderToken: true }); + // files without confidentialKeys import exactly as before + expect(byId.env_plain).not.toHaveProperty('environmentType'); + expect(byId.env_plain).not.toHaveProperty('kvPairData'); + }); + }); + describe('Edge Cases', () => { it('imports collection without meta', () => { const yaml = ` diff --git a/packages/insomnia/src/common/import-v5-parser.ts b/packages/insomnia/src/common/import-v5-parser.ts index 031171f9fc..9828ba8b71 100644 --- a/packages/insomnia/src/common/import-v5-parser.ts +++ b/packages/insomnia/src/common/import-v5-parser.ts @@ -82,6 +82,8 @@ export const CookieJarSchema = z.object({ const { name, data, color, dataPropertyOrder, ...rest } = environment.baseEnvironmentSchema.omit({ metaSortKey: true, }).shape; +// Names of the confidential variables in `data`; see getConfidentialKeys in insomnia-v5.ts. +const confidentialKeys = z.array(z.string()).optional(); export const EnvironmentSchema = z.object({ name, meta: MetaSchema.optional(), @@ -96,11 +98,13 @@ export const EnvironmentSchema = z.object({ dataPropertyOrder, color, ...rest, + confidentialKeys, }), ) .optional(), dataPropertyOrder, ...rest, + confidentialKeys, }); const { @@ -201,6 +205,7 @@ export const RequestGroupSchema = z.object({ }) .optional(), ...restRequestGroupSchemaShape, + confidentialKeys, }); const { @@ -239,6 +244,39 @@ export const RequestSchema = z.object({ pathParameters: requestPathParametersShape.nullable(), }); +/** + * A WebSocket/Socket.IO request's body lives in its own document (WebSocketPayload / + * SocketIOPayload), not on the request itself. `meta.id` carries that document's real id so + * that re-importing a file updates the same document instead of orphaning it and creating a + * new one — which is what git sync does on every pull. + */ +export const WebsocketRequestBodySchema = z.object({ + meta: MetaSchema.extend({ + id: z.string().startsWith('ws-payload'), + }).optional(), + name: z.string().optional().default(''), + value: z.string().optional().default(''), + mode: z.string().optional().default('application/json'), +}); + +export const SocketIORequestBodySchema = z.object({ + meta: MetaSchema.extend({ + id: z.string().startsWith('socket-io-payload'), + }).optional(), + eventName: z.string().optional().default(''), + ack: z.boolean().optional().default(false), + args: z + .array( + z.object({ + id: z.string(), + value: z.string().optional().default(''), + mode: z.string().optional().default('application/json'), + }), + ) + .optional() + .default([]), +}); + const { url: webSocketRequestUrlShape, name: webSocketRequestNameShape, @@ -264,6 +302,7 @@ export const WebsocketRequestSchema = z.object({ }), ...restWebSocketRequestSchemaShape, pathParameters: webSocketRequestPathParametersShape.nullable(), + body: WebsocketRequestBodySchema.optional(), }); const { @@ -288,6 +327,7 @@ export const SocketIORequestSchema = z.object({ path: undefined, }), ...restSocketIORequestSchemaShape, + body: SocketIORequestBodySchema.optional(), }); // Keep the key order consistent so that export to YAML maintains the same order as before: meta sits after authentication @@ -328,6 +368,12 @@ const RequestGroupWithChildrenSchema: z.ZodType = RequestGroupSche pathParameters: z.undefined(), }); +/** + * NOTE: this is a plain union, not a discriminated one, so branch order and the `z.undefined()` + * guards below are what keep the request types apart. Any key unique to the WebSocket or the + * Socket.IO branch must also be added as `z.undefined()` to the other one, otherwise an item + * without a `meta.id` prefix to discriminate on matches the wrong branch and loses that key. + */ export const RequestCollectionSchema = z .union([ GRPCRequestSchema.extend({ @@ -343,11 +389,24 @@ export const RequestCollectionSchema = z // These undefined properties are added to differentiate between the different types of children in the union children: z.undefined(), method: z.undefined(), + // Socket.IO-only keys. Without these guards a Socket.IO item that has no `meta` matches + // this branch and silently loses them, because z.object strips unknown keys. + eventListeners: z.undefined(), + body: WebsocketRequestBodySchema.extend({ + eventName: z.undefined(), + ack: z.undefined(), + args: z.undefined(), + }).optional(), }), SocketIORequestSchema.extend({ // These undefined properties are added to differentiate between the different types of children in the union children: z.undefined(), method: z.undefined(), + body: SocketIORequestBodySchema.extend({ + name: z.undefined(), + value: z.undefined(), + mode: z.undefined(), + }).optional(), }), RequestGroupWithChildrenSchema, ]) diff --git a/packages/insomnia/src/common/insomnia-v5.ts b/packages/insomnia/src/common/insomnia-v5.ts index 30316a9c70..fe9acf5b49 100644 --- a/packages/insomnia/src/common/insomnia-v5.ts +++ b/packages/insomnia/src/common/insomnia-v5.ts @@ -28,20 +28,22 @@ import type { RequestGroup, RequestHeader, RequestParameter, + SocketIOPayload, SocketIORequest, UnitTest, UnitTestSuite, + WebSocketPayload, WebSocketRequest, Workspace, WorkspaceScope, } from 'insomnia-data'; -import { EnvironmentKvPairDataType, models } from 'insomnia-data'; +import { EnvironmentKvPairDataType, EnvironmentType, models } from 'insomnia-data'; import { parse, stringify } from 'yaml'; import { type AllExportTypes, MODELS_BY_EXPORT_TYPE } from '~/common/import'; import { migrateToLatestYaml } from '~/common/insomnia-schema-migrations'; import { INSOMNIA_SCHEMA_VERSION } from '~/common/insomnia-schema-migrations/schema-version'; -import { maskVaultEnvironmentData } from '~/common/utils/environment-utils'; +import { getKVPairFromData, maskVaultEnvironmentData } from '~/common/utils/environment-utils'; import { invariant } from '~/common/utils/invariant'; import { database } from './database'; @@ -133,6 +135,81 @@ function mapMeta(resource: Request | WebSocketRequest | SocketIORequest | GrpcRe }; } +/** + * Maps a payload document's metadata to v5 export format. + * + * Deliberately narrower than `mapMeta`: a payload has no description and no sort order, and + * `filterEmptyValue` keeps `""`, `false` and `0`, so reusing `mapMeta` would add three + * meaningless lines to every exported payload. + */ +function mapPayloadMeta(payload: SocketIOPayload | WebSocketPayload) { + return { + id: payload._id, + created: payload.created, + modified: payload.modified, + }; +} + +/** + * Maps a Socket.IO request's payload document to the v5 `body` field. + * + * Merely opening a Socket.IO request creates an empty payload document (via + * `services.socketIOPayload.getOrCreateByParentId`), so an untouched payload is omitted + * entirely — otherwise every repo would gain a spurious `body:` block and a git diff. This + * cannot be delegated to `removeEmptyFields`, which preserves `""` and `false`. + */ +function mapSocketIOBody(payload?: SocketIOPayload): Insomnia_SocketIORequest['body'] { + if (!payload) { + return; + } + + // Compared against the model's own defaults rather than just checking for empty values: an arg + // the user added but left blank, or whose content type they changed, is real configuration. + // Calling it untouched would drop it from the file and let `deleteOrphans` delete the payload + // on the next sync. `init()` cannot be compared wholesale — it mints a fresh arg id each call. + const defaults = models.socketIOPayload.init(); + const args = payload.args ?? []; + const isUntouched = + !payload.eventName && + !payload.ack && + args.length === defaults.args.length && + args.every((arg, index) => !arg.value && arg.mode === defaults.args[index].mode); + + if (isUntouched) { + return; + } + + return { + meta: mapPayloadMeta(payload), + eventName: payload.eventName ?? '', + ack: payload.ack ?? false, + // Emit every arg once any of them has content, so arg ids, order and per-arg mode survive + args, + }; +} + +/** + * Maps a WebSocket request's payload document to the v5 `body` field. See `mapSocketIOBody` + * for why an untouched payload is omitted. + */ +function mapWebSocketBody(payload?: WebSocketPayload): Insomnia_WebsocketRequest['body'] { + if (!payload) { + return; + } + + const defaults = models.webSocketPayload.init(); + if (payload.name === defaults.name && payload.value === defaults.value && payload.mode === defaults.mode) { + return; + } + + return { + meta: mapPayloadMeta(payload), + name: payload.name, + value: payload.value, + mode: payload.mode, + }; +} + /** * Maps metadata from RequestGroup to v5 export format * Similar to mapMeta but specifically for request groups @@ -227,6 +304,56 @@ function filterEmptyValue(value: string | number | boolean | null | undefined) { return true; } +/** + * v5 files carry an environment's variables only as `data`, so its KV rows -- and the isConfidential + * flag that lives on them -- are rebuilt on import. `confidentialKeys` carries just the names of the + * confidential variables so the flag survives export/import, Duplicate / Move and Git Sync. + * + * Mirrors getDataFromKVPair: only the last enabled row with a given name takes effect. Sorted so the + * output is stable across import/export round-trips (canonical-file relies on that). + */ +function getConfidentialKeys( + environmentType: EnvironmentType | undefined, + kvPairData: EnvironmentKvPairData[] | undefined, +): string[] { + if (environmentType !== EnvironmentType.KVPAIR || !kvPairData) { + return []; + } + const isConfidentialByName = new Map(); + for (const pair of kvPairData) { + if (pair.enabled && pair.name) { + isConfidentialByName.set(pair.name, !!pair.isConfidential); + } + } + return [...isConfidentialByName] + .filter(([, isConfidential]) => isConfidential) + .map(([name]) => name) + .sort(); +} + +/** + * Inverse of getConfidentialKeys: rebuilds the KV rows from `data` and flags the confidential ones. + * Confidential flags only exist in the table view, so the environment is restored as KVPAIR. + * Without confidentialKeys nothing is added, which keeps the import unchanged for every other file. + */ +function getKvPairFieldsFromConfidentialKeys( + data: Record, + dataPropertyOrder: Record | null | undefined, + confidentialKeys: string[] | undefined, +): { environmentType?: EnvironmentType; kvPairData?: EnvironmentKvPairData[] } { + if (!confidentialKeys?.length) { + return {}; + } + const confidentialNames = new Set(confidentialKeys); + return { + environmentType: EnvironmentType.KVPAIR, + kvPairData: getKVPairFromData(data, dataPropertyOrder ?? null).map(pair => ({ + ...pair, + isConfidential: confidentialNames.has(pair.name), + })), + }; +} + /** * Recursively removes empty fields from an object or array * This is used to clean up export data by removing null, undefined, and empty objects @@ -327,6 +454,11 @@ function getEnvironments(file: InsomniaFile): Environment[] { color: file.environments.color || null, data: (file.environments.data as Record) || {}, dataPropertyOrder: (file.environments.dataPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (file.environments.data as Record) || {}, + file.environments.dataPropertyOrder, + file.environments.confidentialKeys, + ), name: file.environments.name || 'Base Environment', }; @@ -342,6 +474,11 @@ function getEnvironments(file: InsomniaFile): Environment[] { color: environment.color || null, data: (environment.data as Record) || {}, dataPropertyOrder: (environment.dataPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (environment.data as Record) || {}, + environment.dataPropertyOrder, + environment.confidentialKeys, + ), name: environment.name || `Environment ${index}`, parentId: baseEnvironment._id, })) || []; @@ -498,11 +635,38 @@ function getTestSuites(file: InsomniaFile): (UnitTestSuite | UnitTest)[] { return []; } +/** + * Tells a Socket.IO collection node apart from a WebSocket one. + * + * `WebsocketRequestSchema.safeParse` cannot do this on its own: once `metaSortKey` is + * destructured out neither schema has a required field, and `z.object` strips unknown keys, so a + * Socket.IO item parses cleanly as a WebSocket request and silently loses its `eventListeners` + * and `body`. The `z.undefined()` guards on `RequestCollectionSchema` keep the two apart inside + * the union, but `walkCollection` routes with the base schemas, so it needs its own check. + * + * `meta.id` is authoritative when present — every Insomnia-generated file has it. Otherwise fall + * back to keys only one of the two can carry. An item with none of them is genuinely + * indistinguishable and stays on the WebSocket branch, as it did before. + */ +function isSocketIOCollectionNode(item: Record): boolean { + if (models.socketIORequest.isSocketIORequestId(item.meta?.id)) { + return true; + } + if (models.webSocketRequest.isWebSocketRequestId(item.meta?.id)) { + return false; + } + if (item.eventListeners || item.settings?.path !== undefined) { + return true; + } + const body = item.body; + return Boolean(body && ('eventName' in body || 'ack' in body || 'args' in body)); +} + function getCollection( file: InsomniaFile, -): (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] { +): (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] { if (file.type === 'collection.insomnia.rest/5.0' || file.type === 'spec.insomnia.rest/5.0') { - const resources: (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] = []; + const resources: (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] = []; function walkCollection( collection: Extract['collection'], @@ -530,6 +694,11 @@ function getCollection( environment: (item.environment as Record) || {}, // 🚧 WARNING 🚧 If we set the order to an empty object instead of undefined it will remove the environment from the folder due to filtering logic (related to json-order) environmentPropertyOrder: (item.environmentPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (item.environment as Record) || {}, + item.environmentPropertyOrder, + item.confidentialKeys, + ), }; resources.push(requestGroup); @@ -598,7 +767,7 @@ function getCollection( resources.push(grpcRequest); } else { const wbRequest = WebsocketRequestSchema.safeParse(item); - if (wbRequest.success) { + if (wbRequest.success && !isSocketIOCollectionNode(item)) { const data = wbRequest.data; const websocketRequest: WithExportType = { ...mapMetaToInsomniaMeta( @@ -624,6 +793,34 @@ function getCollection( }; resources.push(websocketRequest); + + if (data.body) { + const webSocketPayload: WithExportType = { + // Derived from the owning request's id rather than a shared literal placeholder: + // `importResourcesToNewWorkspace` keys its id map by `_id`, so a shared one would + // collapse every payload in the file into a single document. + // + // It must also carry the model's own prefix. Git Sync persists this `_id` + // verbatim, and the next export validates it against `WebsocketRequestBodySchema` + // — a non-conforming id throws there, which empties the export and silently stops + // db-to-fs sync for the whole workspace. + ...mapMetaToInsomniaMeta( + data.body.meta || { + id: `${models.webSocketPayload.prefix}_${websocketRequest._id}`, + }, + ), + type: 'WebSocketPayload', + _type: 'websocket_payload', + // Must be the request's id as it appears in this same flat array, so that the + // import's second pass can resolve it through the id map + parentId: websocketRequest._id, + name: data.body.name, + value: data.body.value, + mode: data.body.mode, + }; + + resources.push(webSocketPayload); + } } else { const socketIORequest = SocketIORequestSchema.safeParse(item); if (socketIORequest.success) { @@ -653,6 +850,28 @@ function getCollection( }; resources.push(socketIO); + + if (data.body) { + const socketIOPayload: WithExportType = { + // See the WebSocket payload above for why the id is prefixed and derived from + // the owning request + ...mapMetaToInsomniaMeta( + data.body.meta || { + id: `${models.socketIOPayload.prefix}_${socketIO._id}`, + }, + ), + type: 'SocketIOPayload', + _type: 'socketio_payload', + parentId: socketIO._id, + // A Socket.IO payload has no user-facing name; '' is the base model's default + name: '', + eventName: data.body.eventName, + ack: data.body.ack, + args: data.body.args, + }; + + resources.push(socketIOPayload); + } } } } @@ -856,6 +1075,16 @@ export async function getInsomniaV5DataExport({ return false; }); + // Payload documents are children of their request rather than collection nodes of their own, + // so they are looked up by parentId while building the owning request's node, and are never + // passed to `getCollectionFromResources`. + const socketIOPayloadsByRequestId = new Map( + exportableResources.filter(models.socketIOPayload.isSocketIOPayload).map(payload => [payload.parentId, payload]), + ); + const webSocketPayloadsByRequestId = new Map( + exportableResources.filter(models.webSocketPayload.isWebSocketPayload).map(payload => [payload.parentId, payload]), + ); + /** * Recursively builds a collection structure from flat resource list * This function converts the flat list of resources into a hierarchical structure @@ -918,6 +1147,7 @@ export async function getInsomniaV5DataExport({ authentication: resource.authentication, environment: resource.environment, environmentPropertyOrder: resource.environmentPropertyOrder, + confidentialKeys: getConfidentialKeys(resource.environmentType, resource.kvPairData), headers: mapHeaders(resource.headers), }; collection.push(requestGroup); @@ -940,6 +1170,7 @@ export async function getInsomniaV5DataExport({ headers: mapHeaders(resource.headers), parameters: mapParameters(resource.parameters), pathParameters: resource.pathParameters, + body: mapWebSocketBody(webSocketPayloadsByRequestId.get(resource._id)), }; collection.push(webSocketRequest); } else if (models.socketIORequest.isSocketIORequest(resource)) { @@ -960,6 +1191,7 @@ export async function getInsomniaV5DataExport({ parameters: mapParameters(resource.parameters), pathParameters: resource.pathParameters, eventListeners: resource.eventListeners, + body: mapSocketIOBody(socketIOPayloadsByRequestId.get(resource._id)), }; collection.push(socketIORequest); } else if (models.grpcRequest.isGrpcRequest(resource)) { @@ -1023,6 +1255,7 @@ export async function getInsomniaV5DataExport({ isPrivate: baseEnvironment.isPrivate, }, data: baseEnvironment.data, + confidentialKeys: getConfidentialKeys(baseEnvironment.environmentType, baseEnvironment.kvPairData), color: baseEnvironment.color, subEnvironments: subEnvironments.map(maskVaultEnvironmentData).map(subEnvironment => ({ name: subEnvironment.name, @@ -1034,6 +1267,7 @@ export async function getInsomniaV5DataExport({ sortKey: subEnvironment.metaSortKey, }, data: subEnvironment.data, + confidentialKeys: getConfidentialKeys(subEnvironment.environmentType, subEnvironment.kvPairData), color: subEnvironment.color, })), }; @@ -1237,7 +1471,8 @@ export async function getInsomniaV5DataExport({ models.requestGroup.isRequestGroup(resource) || models.request.isRequest(resource) || models.webSocketRequest.isWebSocketRequest(resource) || - models.grpcRequest.isGrpcRequest(resource), + models.grpcRequest.isGrpcRequest(resource) || + models.socketIORequest.isSocketIORequest(resource), ), workspace._id, ), diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 6f0a511a5e..2832eaa66f 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -246,6 +246,7 @@ const git: GitServiceAPI = { stagePartialContent: options => invokeWithNormalizedError('git.stagePartialContent', options), discardPartialContent: options => invokeWithNormalizedError('git.discardPartialContent', options), diffFileLoader: options => invokeWithNormalizedError('git.diffFileLoader', options), + fileFormatIssueLoader: options => invokeWithNormalizedError('git.fileFormatIssueLoader', options), getRepositoryDirectoryTree: options => invokeWithNormalizedError('git.getRepositoryDirectoryTree', options), migrateLegacyInsomniaFolderToFile: options => invokeWithNormalizedError('git.migrateLegacyInsomniaFolderToFile', options), diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index b12673032c..d203ecbc58 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -51,7 +51,12 @@ import { insomniaSchemaTypeToScope } from '../common/insomnia-v5'; import { buildInsomniaChangeReport, type InsomniaChangeReport } from '../common/insomnia-v5-change-report'; import { checkStrictSchemaMatch } from '../common/zod/check-schema-mismatch'; import { findUnknownFieldRanges, type UnknownFieldRange } from '../common/zod/find-unknown-field-ranges'; -import { canonicalizeInsomniaFileContent, isUndiscardableChange } from '../sync/git/canonical-file'; +import { + canonicalizeInsomniaFileContent, + getInsomniaFileFormatIssue, + type InsomniaFileFormatIssue, + isUndiscardableChange, +} from '../sync/git/canonical-file'; import { fsClient } from '../sync/git/fs-client'; import { CURRENT_MIGRATION_VERSION, migrateRepoStructureIfNeeded } from '../sync/git/git-repo-migration'; import GitVCS, { @@ -3592,6 +3597,37 @@ export const diffFileLoader = async ({ } }; +/** + * Whether either side of a file's diff isn't a file as Insomnia writes it (eg. edited by hand), which the + * Visual view can't reliably show or act on. Kept out of `diffFileLoader` so the import -> export replay it + * takes doesn't hold up showing the diff. + */ +export const fileFormatIssueLoader = async ({ + projectId, + workspaceId, + filepath, + staged, +}: { + projectId: string; + workspaceId?: string; + filepath: string; + staged: boolean; +}): Promise => { + try { + await getGitRepository({ workspaceId, projectId }); + const fileStatus = await GitVCS.fileStatus(filepath); + // The raw contents: the diff's `before` is migrated and reordered for display + const sides = staged + ? [fileStatus.rawHead, fileStatus.rawStage] + : [fileStatus.rawStage || fileStatus.rawHead, fileStatus.workdir]; + const issues = await Promise.all(sides.map(getInsomniaFileFormatIssue)); + return issues.includes('invalid') ? 'invalid' : issues.includes('non-canonical') ? 'non-canonical' : null; + } catch (err) { + console.warn('[git] Failed to check the format of the file', filepath, err); + return null; + } +}; + interface GitRepoFile { id: string; name: string; @@ -4110,6 +4146,7 @@ export interface GitServiceAPI { stagePartialContent: typeof stagePartialContentAction; discardPartialContent: typeof discardPartialContentAction; diffFileLoader: typeof diffFileLoader; + fileFormatIssueLoader: typeof fileFormatIssueLoader; getRepositoryDirectoryTree: typeof getRepositoryDirectoryTree; migrateLegacyInsomniaFolderToFile: typeof migrateLegacyInsomniaFolderToFile; fetchGitRemoteBranches: typeof fetchGitRemoteBranches; @@ -4226,6 +4263,9 @@ export const registerGitServiceAPI = () => { discardPartialContentAction(options), ); ipcMainHandle('git.diffFileLoader', (_, options: Parameters[0]) => diffFileLoader(options)); + ipcMainHandle('git.fileFormatIssueLoader', (_, options: Parameters[0]) => + fileFormatIssueLoader(options), + ); ipcMainHandle('git.getRepositoryDirectoryTree', (_, options: Parameters[0]) => getRepositoryDirectoryTree(options), ); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 5fc2f864a0..5d35aaac74 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -62,6 +62,7 @@ export type HandleChannels = | 'git.deleteGitBranch' | 'git.diff' | 'git.diffFileLoader' + | 'git.fileFormatIssueLoader' | 'git.discardChanges' | 'git.fetchGitRemoteBranches' | 'git.getProjectGitFileIssues' diff --git a/packages/insomnia/src/main/mcp/transport-streamable-http.ts b/packages/insomnia/src/main/mcp/transport-streamable-http.ts index 9b413154a5..400f5e91ba 100644 --- a/packages/insomnia/src/main/mcp/transport-streamable-http.ts +++ b/packages/insomnia/src/main/mcp/transport-streamable-http.ts @@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { BrowserWindow } from 'electron'; import type { McpResponse, RequestHeader } from 'insomnia-data'; import { models, services } from 'insomnia-data'; -import type { Dispatcher } from 'undici'; +import { type Dispatcher, fetch as undiciFetch } from 'undici'; import { redactConfidentialText } from '~/common/network/sensitive-value-collector'; import { type ConnectionContext, getFetchDispatcher, writeEventLogAndNotify, writeTimeline } from '~/main/mcp/common'; @@ -21,6 +21,11 @@ interface NodeRequestInit extends RequestInit { dispatcher?: Dispatcher; } +// Use undici's own fetch: the global (Electron built-in) fetch ships an older undici whose handler API +// is incompatible with dispatchers created by the installed undici. +const dispatcherFetch = (url: string | URL, init: NodeRequestInit) => + undiciFetch(url, init as Parameters[1]) as unknown as Promise; + export const createStreamableHTTPTransport = async ( context: ConnectionContext, options: OpenMcpHTTPClientConnectionOptions, @@ -110,7 +115,7 @@ const wrappedFetch = async ( const requestMethodLine = `${method.toUpperCase()} ${url} ${isJsonRequest && requestBody?.method ? `\nJSON-RPC Method: ${requestBody.method}` : ''}`; const headersOut = requestHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n'); const start = performance.now(); - const response = await fetch(url, { + const response = await dispatcherFetch(url, { ...init, dispatcher: await getFetchDispatcher(requestId), } as NodeRequestInit); @@ -205,7 +210,7 @@ const wrappedFetch = async ( }; writeEventLogAndNotify(context, authRequestEvent); try { - const response = await fetch(url, { + const response = await dispatcherFetch(url, { ...init, dispatcher: await getFetchDispatcher(requestId), } as NodeRequestInit); diff --git a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts index c40ea82ea0..467c7d5403 100644 --- a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts @@ -12,6 +12,7 @@ import { getInsomniaV5DataExport, tryImportV5Data } from '../../../common/insomn import { canonicalizeInsomniaFileContent, getCanonicalInsomniaFileContent, + getInsomniaFileFormatIssue, isUndiscardableChange, } from '../canonical-file'; @@ -146,6 +147,37 @@ routes: }); }); + describe('getInsomniaFileFormatIssue', () => { + it('finds no issue in a file Insomnia wrote itself', async () => { + expect(await getInsomniaFileFormatIssue(await exportThroughDatabase(reorderedCollection))).toBeNull(); + }); + + it('flags a valid file Insomnia would write differently, eg. one edited by hand', async () => { + const exported = await exportThroughDatabase(reorderedCollection); + const withComment = exported.replace('collection:\n', 'collection:\n # added by hand\n'); + const withDuplicateId = buildCollection([ + { id: 'fld_a', name: 'Folder A', created: 2000 }, + { id: 'fld_a', name: 'Folder A copy', created: 3000 }, + ]); + + expect(await getInsomniaFileFormatIssue(reorderedCollection)).toBe('non-canonical'); + expect(await getInsomniaFileFormatIssue(withComment)).toBe('non-canonical'); + expect(await getInsomniaFileFormatIssue(withDuplicateId)).toBe('non-canonical'); + }); + + it('flags an Insomnia file that does not match the file format', async () => { + const notAList = 'type: collection.insomnia.rest/5.0\nname: C\ncollection: not a list\n'; + + expect(await getInsomniaFileFormatIssue(notAList)).toBe('invalid'); + }); + + it('ignores empty sides and files that are not Insomnia files', async () => { + expect(await getInsomniaFileFormatIssue(null)).toBeNull(); + expect(await getInsomniaFileFormatIssue('')).toBeNull(); + expect(await getInsomniaFileFormatIssue('openapi: 3.0.0\ninfo:\n title: Spec\n')).toBeNull(); + }); + }); + describe('getCanonicalInsomniaFileContent', () => { it.each(insomniaFixtures)( 'matches the real import/export pipeline for %s', @@ -179,6 +211,19 @@ routes: expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent); }); + it('keeps confidentialKeys through the round-trip so the flag survives Git Sync', async () => { + const content = `${buildCollection([{ id: 'fld_a', name: 'Folder A', created: 2000 }])} data: + token: secret + host: example.com + confidentialKeys: + - token +`; + const canonicalContent = await getCanonicalInsomniaFileContent(content); + expect(canonicalContent).toBe(await exportThroughDatabase(content)); + expect(YAML.parse(canonicalContent!).environments.confidentialKeys).toEqual(['token']); + expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent); + }); + it('does not read from, write to or notify the app database', async () => { await db.update({ _id: 'req_app', type: models.request.type, parentId: 'wrk_app' } as BaseModel); const appChanges: unknown[] = []; diff --git a/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts new file mode 100644 index 0000000000..b9d5dd4a91 --- /dev/null +++ b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts @@ -0,0 +1,139 @@ +/** + * Socket.IO / WebSocket payloads through the Git Sync project fs→db path. + * + * Lives in its own file because `repo-file-watcher.test.ts` module-mocks `common/insomnia-v5`, + * and these tests need the real import/export. + * + * Before the v5 format carried the payload, `deleteOrphans` removed every payload document on + * each real fs→db sync — a `git pull`, a `checkout`, an edit to any part of the YAML, or just + * restarting the app — because the importer could never produce one. See INS-3123. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { services } from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { database as db } from '../../../common/database'; +import { RepoFileWatcherRegistry } from '../repo-file-watcher'; + +const REPO_ID = 'git_repo_payload'; +const PROJECT_ID = 'proj_payload'; +const WORKSPACE_ID = 'wrk_payload'; +const REQUEST_ID = 'socketio-req_payload'; +const PAYLOAD_ID = 'socket-io-payload_payload'; +const YAML_PATH = 'insomnia.wrk_payload.yaml'; + +const collectionYaml = ({ withBody = true, withRequest = true, requestName = 'Rooms' } = {}) => `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Payload Collection +meta: + id: ${WORKSPACE_ID} + created: 1000 + modified: 1000 +collection:${ + withRequest + ? ` + - url: https://socket.example.com + name: ${requestName} + meta: + id: ${REQUEST_ID} + created: 1000 + modified: 1000 + sortKey: 1${ + withBody + ? ` + body: + meta: + id: ${PAYLOAD_ID} + created: 1000 + modified: 1000 + eventName: chat message + ack: true + args: + - id: arg1 + value: '"world"' + mode: application/json` + : '' + }` + : ' []' +} +environments: + name: Base Environment + meta: + id: env_payload + created: 1000 + modified: 1000 + isPrivate: false +`; + +describe('RepoFileWatcher keeps Socket.IO payloads through fs→db syncs', () => { + let repoDir: string; + let registry: RepoFileWatcherRegistry; + let absPath: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + await services.project.create({ _id: PROJECT_ID, name: 'Payload Project' }); + repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-repo-watcher-payload-')); + absPath = path.join(repoDir, YAML_PATH); + registry = new RepoFileWatcherRegistry({ onDbSynced: vi.fn(), onProblemsChanged: vi.fn() }); + }); + + afterEach(async () => { + registry.stopAll(); + await fs.promises.rm(repoDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('imports a body into a real payload document keyed by the id in the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({ + _id: PAYLOAD_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + }); + + // Re-importing the file is what a `git pull` or `checkout` triggers — the exact moment the + // payload used to be deleted as an orphan. + // + // The edit has to change the file: `readIfChanged` dedups on a content hash, so re-importing + // byte-identical content returns early and `deleteOrphans` never runs at all. + it('keeps the payload when an unrelated edit re-imports the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + + await fs.promises.writeFile(absPath, collectionYaml({ requestName: 'Rooms renamed' }), 'utf8'); + await registry.importAllFiles(REPO_ID); + await registry.flushNow(REPO_ID); + + // Proves the import actually ran, so the payload assertion below is not vacuous + await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toMatchObject({ + name: 'Rooms renamed', + }); + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({ + _id: PAYLOAD_ID, + eventName: 'chat message', + }); + }); + + it('deletes the payload along with its request when the request leaves the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toBeDefined(); + + await fs.promises.writeFile(absPath, collectionYaml({ withRequest: false }), 'utf8'); + await registry.importAllFiles(REPO_ID); + await registry.flushNow(REPO_ID); + + await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toBeUndefined(); + await expect(services.socketIOPayload.getById(PAYLOAD_ID)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/insomnia/src/sync/git/canonical-file.ts b/packages/insomnia/src/sync/git/canonical-file.ts index 79e4e7b8da..788824775d 100644 --- a/packages/insomnia/src/sync/git/canonical-file.ts +++ b/packages/insomnia/src/sync/git/canonical-file.ts @@ -22,9 +22,7 @@ import { hasSignificantChanges } from '~/common/significant-diff-detection'; * or null if `content` is not an importable Insomnia file. */ export async function getCanonicalInsomniaFileContent(content: string): Promise { - const newLineIndex = content.indexOf('\n'); - const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim(); - if (!InsomniaFileTypeValues.some(type => firstLine.includes(type))) { + if (!isInsomniaFileContent(content)) { return null; } @@ -53,6 +51,32 @@ export async function getCanonicalInsomniaFileContent(content: string): Promise< return exportedContent || null; } +// Whether `content` declares itself an Insomnia file on its first line (eg. `type: collection.insomnia.rest/5.0`). +function isInsomniaFileContent(content: string) { + const newLineIndex = content.indexOf('\n'); + const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim(); + return InsomniaFileTypeValues.some(type => firstLine.includes(type)); +} + +export type InsomniaFileFormatIssue = 'invalid' | 'non-canonical'; + +/** + * How `content` differs from a file Insomnia writes itself: `invalid` when it's an Insomnia file that + * doesn't match the file format, `non-canonical` when it does but Insomnia would write it differently + * (eg. it was edited by hand — comments, formatting, order, missing or duplicated parts, unrecognized + * fields — or written by another version of Insomnia), and null when it's exactly what Insomnia writes, + * empty, or not an Insomnia file at all. + */ +export async function getInsomniaFileFormatIssue(content: string | null): Promise { + if (!content || !isInsomniaFileContent(content)) { + return null; + } + if (tryImportV5Data(content).error) { + return 'invalid'; + } + return (await getCanonicalInsomniaFileContent(content)) === content ? null : 'non-canonical'; +} + /** * `content` as the Git Sync pipeline would write it, or `content` itself when it can't be replayed * (not an importable Insomnia file, or a workspace the export can't produce, e.g. a file without a diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index 9bb240c513..1b94f950ec 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -378,6 +378,11 @@ export const OneLineEditor = forwardRef // moved to another control (e.g. Tab from the URL bar to Send) — until the editor holds focus // or the window elapses. const deadline = Date.now() + 500; + // The control that held focus when we mounted (typically the "Add" button that triggered this + // editor). It keeps focus until we take it, so it must not be mistaken for the user having + // deliberately moved elsewhere. Cleared once the editor holds focus, so a later deliberate + // return to that control is respected. + let initialActiveElement = document.activeElement; const ensureFocus = () => { const cm = codeMirror.current; if (!cm) { @@ -399,6 +404,7 @@ export const OneLineEditor = forwardRef const role = active?.getAttribute('role'); const userMovedToAnotherControl = !!active && + active !== initialActiveElement && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT' || @@ -416,6 +422,8 @@ export const OneLineEditor = forwardRef } cm.focus(); cm.getDoc().setCursor(cm.getDoc().lineCount(), 0); + } else { + initialActiveElement = null; } if (Date.now() < deadline) { requestAnimationFrame(ensureFocus); diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 2753ddbefc..8d98cd0c3a 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -1956,6 +1956,9 @@ const OriginalGitProjectStagingModal: FC< onShowTextView={() => setDiffViewMode('text')} actionGate={actionGate} isActionInFlight={isActionInFlight} + // One warning above the diff at most: unrecognized fields, or a change that is only Insomnia + // reformatting the file, already have their own, which say more than the format warning would. + hideFormatWarning={Boolean(hasUnknownFields || undiscardableFilePath)} /> )} diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx index 512af15f9d..14e1efcf2c 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx +++ b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx @@ -5,6 +5,7 @@ import { useGitProjectDiscardPartialContentActionFetcher } from '~/routes/git.di import { useGitProjectStagePartialContentActionFetcher } from '~/routes/git.stage-entity'; import { showToast } from '../../toast-notification'; +import { WarningBanner } from '../../warning-banner'; import type { ActionGate } from './action-gate'; import { CookieJarDiffCard } from './cookie-jar-diff-card'; import { computeVisualDiff, getBaseEnvironmentId } from './diff-engine'; @@ -41,8 +42,12 @@ interface Props { actionGate: ActionGate; // Whether an action, here or file-level, is still in flight: every card's buttons wait for it. isActionInFlight: boolean; + // Set when the diff already has a warning saying more than the format warning would (eg. unrecognized fields). + hideFormatWarning?: boolean; } +type FormatIssue = Awaited>; + // Shown instead of cards when there are none to show; the Text view still has the full diff. const TextViewHint: FC<{ message: string; onShowTextView: () => void }> = ({ message, onShowTextView }) => (
@@ -67,8 +72,32 @@ export const EntityDiffList: FC = ({ onShowTextView, actionGate, isActionInFlight, + hideFormatWarning, }) => { const { entities, unparseable } = useMemo(() => computeVisualDiff(before, after), [before, after]); + + // Whether either side isn't a file as Insomnia writes it (eg. edited by hand): `invalid` doesn't match the + // Insomnia file format at all, `non-canonical` does but Insomnia would write it differently. Checked in the + // background, as it replays both sides through import -> export, so it doesn't hold up showing the diff. + // Kept per file, so a refresh after an action doesn't flash the warning, and another file never shows it. + const fileKey = `${filepath}:${staged}`; + const [formatCheck, setFormatCheck] = useState<{ fileKey: string; issue: FormatIssue } | null>(null); + useEffect(() => { + let cancelled = false; + window.main.git + .fileFormatIssueLoader({ projectId, workspaceId, filepath, staged }) + .then(issue => { + if (!cancelled) { + setFormatCheck({ fileKey, issue }); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [projectId, workspaceId, filepath, staged, fileKey, before, after]); + const formatIssue = formatCheck?.fileKey === fileKey ? formatCheck.issue : null; + const baseEnvironmentId = useMemo(() => getBaseEnvironmentId(after), [after]); const stagePartialContentFetcher = useGitProjectStagePartialContentActionFetcher(); const discardPartialContentFetcher = useGitProjectDiscardPartialContentActionFetcher(); @@ -152,6 +181,15 @@ export const EntityDiffList: FC = ({ return ; } + if (formatIssue === 'invalid') { + return ( + + ); + } + if (entities.length === 0) { // Also covers metadata-only (eg. timestamps) and formatting-only changes, and non-Insomnia files // such as the project's .spectral.yaml lint ruleset — not just moves. @@ -173,6 +211,18 @@ export const EntityDiffList: FC = ({ return (
+ {formatIssue === 'non-canonical' && !hideFormatWarning && ( + // Cards are matched up by id and actions splice entities into a file written the way Insomnia exports it, + // so a file in any other shape (eg. edited by hand) can show incomplete cards and lose what isn't an entity. + + This file isn't in the format Insomnia writes, eg. it was edited by hand or by another version. Cards may + be incomplete, and staging or discarding a card rewrites the whole file, losing comments and formatting.{' '} + {' '} + for the exact changes. + + )}
{entities.length} {entities.length === 1 ? 'entity' : 'entities'} changed diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 1e17e13885..559835ec7b 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -837,33 +837,6 @@ const ProjectNavigationSidebarInner = ( }); }); }, - sidebar_deleteItem: event => { - // Only act when a row itself is focused, not an inner control (button/input), so Backspace keeps editing text. - const active = document.activeElement; - if (!(active instanceof HTMLElement) || active.closest('[data-key]') !== active) { - return; - } - const { focusedItem } = getFocusedItem(); - if ( - !focusedItem || - (focusedItem.kind !== 'project' && - focusedItem.kind !== 'workspace' && - focusedItem.kind !== 'collectionChild' && - focusedItem.kind !== 'pinnedRequest') - ) { - return; - } - - event.preventDefault(); - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.keyboardShortcutUsed, - properties: { - source: 'navigation-sidebar', - action: 'deleteItem', - }, - }); - showDeleteModal(focusedItem); - }, }); const { hasKonnectPat } = settings; @@ -991,7 +964,7 @@ const ProjectNavigationSidebarInner = ( className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)" data-testid="project-navigation-tree-container" onKeyDownCapture={(e: React.KeyboardEvent) => { - if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft' && e.key !== 'Delete') { return; } const active = document.activeElement; @@ -1003,6 +976,29 @@ const ProjectNavigationSidebarInner = ( if (!rowEl || rowEl !== active) { return; } + if (e.key === 'Delete') { + const { focusedItem } = getFocusedItem(); + if ( + !focusedItem || + (focusedItem.kind !== 'project' && + focusedItem.kind !== 'workspace' && + focusedItem.kind !== 'collectionChild' && + focusedItem.kind !== 'pinnedRequest') + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.keyboardShortcutUsed, + properties: { + source: 'navigation-sidebar', + action: 'deleteItem', + }, + }); + showDeleteModal(focusedItem); + return; + } const docId = (active.dataset.key || '').replace(/^pinned-request-/, ''); const item = visibleFlatItems.find(i => i.doc._id === docId && i.kind !== 'pinnedRequest'); if (!item) { diff --git a/schemas/insomnia.schema.5.1.json b/schemas/insomnia.schema.5.1.json index 0b602bb4d3..2f43398414 100644 --- a/schemas/insomnia.schema.5.1.json +++ b/schemas/insomnia.schema.5.1.json @@ -310,6 +310,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -369,6 +375,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -892,6 +904,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -951,6 +969,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -1525,6 +1549,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -1584,6 +1614,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -2348,6 +2384,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -2407,6 +2449,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -3866,8 +3914,53 @@ } ] }, + "body": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ws-payload.*" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "eventName": {}, + "ack": {}, + "args": {} + } + }, "children": {}, - "method": {} + "method": {}, + "eventListeners": {} } }, { @@ -4476,6 +4569,67 @@ "disableUserAgentHeader": { "type": "boolean" }, + "body": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^socket-io-payload.*" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "eventName": { + "type": "string" + }, + "ack": { + "type": "boolean" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "name": {}, + "value": {}, + "mode": {} + } + }, "children": {}, "method": {} } @@ -5068,6 +5222,12 @@ } ] }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } + }, "method": {}, "url": {}, "parameters": {},