mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 12:33:40 -05:00
Merge branch 'develop' into test/refactor_e2e_tests
This commit is contained in:
26 files changed
+1404
-71
No files matched your search
Generated
+8
-18
@@ -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",
|
||||
|
||||
@@ -26,7 +26,6 @@ export const keyboardShortcutDescriptions: Record<KeyboardShortcut, string> = {
|
||||
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 }],
|
||||
|
||||
@@ -47,7 +47,6 @@ export type KeyboardShortcut =
|
||||
| 'request_showGenerateCodeEditor'
|
||||
| 'sidebar_focusFilter'
|
||||
| 'sidebar_showCreateDropdown'
|
||||
| 'sidebar_deleteItem'
|
||||
| 'sidebar_toggle'
|
||||
| 'sidebar_rename'
|
||||
| 'response_focus'
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<any, any> = (call: any) => {
|
||||
export const startGRPCServer = (port: number) => {
|
||||
return new Promise<void>((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, {
|
||||
|
||||
@@ -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"])');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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<RequestGroup> = 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,
|
||||
])
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
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<string, any>,
|
||||
dataPropertyOrder: Record<string, any> | 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<string, any>) || {},
|
||||
dataPropertyOrder: (file.environments.dataPropertyOrder as Record<string, any>) || undefined,
|
||||
...getKvPairFieldsFromConfidentialKeys(
|
||||
(file.environments.data as Record<string, any>) || {},
|
||||
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<string, any>) || {},
|
||||
dataPropertyOrder: (environment.dataPropertyOrder as Record<string, any>) || undefined,
|
||||
...getKvPairFieldsFromConfidentialKeys(
|
||||
(environment.data as Record<string, any>) || {},
|
||||
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<string, any>): 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<InsomniaFile, { type: 'collection.insomnia.rest/5.0' }>['collection'],
|
||||
@@ -530,6 +694,11 @@ function getCollection(
|
||||
environment: (item.environment as Record<string, any>) || {},
|
||||
// 🚧 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<string, any>) || undefined,
|
||||
...getKvPairFieldsFromConfidentialKeys(
|
||||
(item.environment as Record<string, any>) || {},
|
||||
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<WebSocketRequest> = {
|
||||
...mapMetaToInsomniaMeta(
|
||||
@@ -624,6 +793,34 @@ function getCollection(
|
||||
};
|
||||
|
||||
resources.push(websocketRequest);
|
||||
|
||||
if (data.body) {
|
||||
const webSocketPayload: WithExportType<WebSocketPayload> = {
|
||||
// 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<SocketIOPayload> = {
|
||||
// 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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<InsomniaFileFormatIssue | null> => {
|
||||
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<typeof diffFileLoader>[0]) => diffFileLoader(options));
|
||||
ipcMainHandle('git.fileFormatIssueLoader', (_, options: Parameters<typeof fileFormatIssueLoader>[0]) =>
|
||||
fileFormatIssueLoader(options),
|
||||
);
|
||||
ipcMainHandle('git.getRepositoryDirectoryTree', (_, options: Parameters<typeof getRepositoryDirectoryTree>[0]) =>
|
||||
getRepositoryDirectoryTree(options),
|
||||
);
|
||||
|
||||
@@ -62,6 +62,7 @@ export type HandleChannels =
|
||||
| 'git.deleteGitBranch'
|
||||
| 'git.diff'
|
||||
| 'git.diffFileLoader'
|
||||
| 'git.fileFormatIssueLoader'
|
||||
| 'git.discardChanges'
|
||||
| 'git.fetchGitRemoteBranches'
|
||||
| 'git.getProjectGitFileIssues'
|
||||
|
||||
@@ -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<typeof undiciFetch>[1]) as unknown as Promise<Response>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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<InsomniaFileFormatIssue | null> {
|
||||
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
|
||||
|
||||
@@ -378,6 +378,11 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
|
||||
// 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<OneLineEditorHandle, OneLineEditorProps>
|
||||
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<OneLineEditorHandle, OneLineEditorProps>
|
||||
}
|
||||
cm.focus();
|
||||
cm.getDoc().setCursor(cm.getDoc().lineCount(), 0);
|
||||
} else {
|
||||
initialActiveElement = null;
|
||||
}
|
||||
if (Date.now() < deadline) {
|
||||
requestAnimationFrame(ensureFocus);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<ReturnType<typeof window.main.git.fileFormatIssueLoader>>;
|
||||
|
||||
// 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 }) => (
|
||||
<div className="flex h-full flex-1 items-center justify-center p-4 text-center text-(--hl)">
|
||||
@@ -67,8 +72,32 @@ export const EntityDiffList: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
return <TextViewHint message="Unable to parse this file for a visual diff." onShowTextView={onShowTextView} />;
|
||||
}
|
||||
|
||||
if (formatIssue === 'invalid') {
|
||||
return (
|
||||
<TextViewHint
|
||||
message="This file doesn't match the Insomnia file format, so the Visual view can't show it."
|
||||
onShowTextView={onShowTextView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||
{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.
|
||||
<WarningBanner className="shrink-0 text-sm" data-testid="visual-diff-format-warning">
|
||||
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.{' '}
|
||||
<Button onPress={onShowTextView} className="cursor-pointer underline hover:brightness-125">
|
||||
Open the Text view
|
||||
</Button>{' '}
|
||||
for the exact changes.
|
||||
</WarningBanner>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<span className="font-semibold text-(--color-font)">
|
||||
{entities.length} {entities.length === 1 ? 'entity' : 'entities'} changed
|
||||
|
||||
+24
-28
@@ -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) {
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
Reference in new issue
Block a user