diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mcp-client-shows-no-warnings.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mcp-client-shows-no-warnings.spec.ts new file mode 100644 index 0000000000..c9340d70f9 --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mcp-client-shows-no-warnings.spec.ts @@ -0,0 +1,35 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, MCP_SERVER, test } from "../../../misc/fixtures"; +import { McpClient } from '../../../models/mcp-client'; +import { commitEachChange, NO_ROW_WARNINGS, readRowWarnings } from './helpers'; + +test('Verify a Cloud Sync MCP client committed and then edited shows no unknown fields or unsupported warnings and commits without confirmation', async ({ + user, +}) => { + const { workspaceFlow, mcpClientFlow } = user.flowManager; + const { cloudSyncPage, mcpClientPage, cloudSyncCommitDialogPage } = user.pageManager; + + const project = await workspaceFlow.getProject({ name: 'Personal Workspace' }); + const client = await mcpClientFlow.create(project!, new McpClient(faker.string.alphanumeric(10), MCP_SERVER)); + await cloudSyncPage.openCommitDialog(); + const initialChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, initialChangeNames); + + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameMcpClient(client, renamed); + await mcpClientFlow.get(renamed); + await mcpClientPage.setUrl(`${MCP_SERVER}?${faker.string.alpha(6)}=1`); + await cloudSyncPage.openCommitDialog(); + const editedChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + const rowWarnings = await readRowWarnings(cloudSyncCommitDialogPage, editedChangeNames); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, editedChangeNames); + + expect(initialChangeNames.length).toBeGreaterThan(0); + expect(editedChangeNames.length).toBeGreaterThan(0); + expect(rowWarnings).toEqual(editedChangeNames.map(() => NO_ROW_WARNINGS)); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mock-server-shows-no-warnings.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mock-server-shows-no-warnings.spec.ts new file mode 100644 index 0000000000..111ec6f53a --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-mock-server-shows-no-warnings.spec.ts @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { HttpMethod } from '../../../enums/http-method'; +import { DEFAULT_TIMEOUT, MOCK_BIN_SERVER, test } from "../../../misc/fixtures"; +import { MockServer } from '../../../models/mock-server'; +import { commitEachChange, NO_ROW_WARNINGS, readRowWarnings } from './helpers'; + +test('Verify a Cloud Sync mock server with routes committed and then edited shows no unknown fields or unsupported warnings and commits without confirmation', async ({ + user, +}) => { + const { workspaceFlow, mockServerFlow } = user.flowManager; + const { cloudSyncPage, cloudSyncCommitDialogPage } = user.pageManager; + + const project = await workspaceFlow.getProject({ name: 'Personal Workspace' }); + const server = await mockServerFlow.create(project!, new MockServer(faker.string.alphanumeric(10), MOCK_BIN_SERVER)); + const route = await mockServerFlow.createRoute(server, { + path: `/${faker.string.alphanumeric(8)}`, + method: HttpMethod.Post, + statusCode: 201, + body: faker.string.alpha(10), + }); + await mockServerFlow.createRoute(server, { path: `/${faker.string.alphanumeric(8)}` }); + await cloudSyncPage.openCommitDialog(); + const initialChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, initialChangeNames); + + await mockServerFlow.editRoute(route, { path: `/${faker.string.alphanumeric(8)}`, method: HttpMethod.Put }); + await cloudSyncPage.openCommitDialog(); + const editedChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + const rowWarnings = await readRowWarnings(cloudSyncCommitDialogPage, editedChangeNames); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, editedChangeNames); + + expect(initialChangeNames.length).toBeGreaterThan(0); + expect(editedChangeNames.length).toBeGreaterThan(0); + expect(rowWarnings).toEqual(editedChangeNames.map(() => NO_ROW_WARNINGS)); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-project-of-all-document-types-shows-no-warnings.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-project-of-all-document-types-shows-no-warnings.spec.ts new file mode 100644 index 0000000000..8aba04bf90 --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-project-of-all-document-types-shows-no-warnings.spec.ts @@ -0,0 +1,121 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { HttpMethod } from '../../../enums/http-method'; +import { DEFAULT_TIMEOUT, HTTP_SERVER, test, WS_SERVER } from "../../../misc/fixtures"; +import { Collection } from '../../../models/collection'; +import { EnvironmentKvPairDataType } from '../../../models/environment'; +import { Folder } from '../../../models/folder'; + +test('Verify a Cloud Sync collection committed with every document type shows no unknown fields or unsupported warnings when each document is edited and committed again', async ({ + user, +}) => { + // Creates every document type and commits each one twice, which alone takes most of the default 90s. + test.setTimeout(240_000); + const { + workspaceFlow, + folderFlow, + httpRequestFlow, + graphqlRequestFlow, + grpcRequestFlow, + webSocketRequestFlow, + socketIoRequestFlow, + cookieFlow, + } = user.flowManager; + const { cloudSyncPage, environmentPage, cloudSyncCommitDialogPage } = user.pageManager; + + const project = await workspaceFlow.getProject({ name: 'Personal Workspace' }); + const collectionName = faker.string.alphanumeric(10); + const collection = await workspaceFlow.createCollection(project!, new Collection(collectionName)); + const folder = await folderFlow.create(collection as Collection, new Folder(faker.string.alphanumeric(10))); + const httpRequest = await httpRequestFlow.create(collection as Collection, { + name: faker.string.alphanumeric(10), + url: `${HTTP_SERVER}/get`, + method: HttpMethod.Get, + }); + const graphqlRequest = await graphqlRequestFlow.create(collection as Collection, { + name: faker.string.alphanumeric(10), + url: `${HTTP_SERVER}/graphql`, + method: HttpMethod.Post, + }); + const grpcRequest = await grpcRequestFlow.create(collection as Collection, { + name: faker.string.alphanumeric(10), + url: 'localhost:9000', + }); + const webSocketRequest = await webSocketRequestFlow.create(collection as Collection, { + name: faker.string.alphanumeric(10), + url: WS_SERVER, + }); + const socketIoRequest = await socketIoRequestFlow.create(collection as Collection, { + name: faker.string.alphanumeric(10), + url: HTTP_SERVER, + }); + await cookieFlow.link(collection as Collection, [ + { key: faker.string.alpha(6), value: faker.string.alpha(6), domain: 'localhost', path: '/' }, + ]); + await environmentPage.openCollectionEnvironmentEditor(); + await environmentPage.addCollectionEnvironmentVariable({ + id: '', + name: faker.string.alpha(8), + value: faker.string.alpha(8), + type: EnvironmentKvPairDataType.STRING, + }); + + await cloudSyncPage.openCommitDialog(); + const initialChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + await cloudSyncCommitDialogPage.closeCommitDialog(); + for (const name of initialChangeNames) { + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.commit(name, faker.lorem.sentence()); + } + + const renamedRequests = [httpRequest, graphqlRequest, grpcRequest, webSocketRequest, socketIoRequest]; + const renamedNames: string[] = []; + for (const request of renamedRequests) { + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameRequest(request, renamed); + renamedNames.push(renamed); + } + const renamedCollectionName = faker.string.alphanumeric(10); + await workspaceFlow.renameCollection(collection as Collection, renamedCollectionName); + await folderFlow.setScripts(folder, { preRequest: faker.string.alpha(10) }); + await cookieFlow.link(collection as Collection, [ + { key: faker.string.alpha(6), value: faker.string.alpha(6), domain: 'localhost', path: '/' }, + ]); + await environmentPage.openCollectionEnvironmentEditor(); + await environmentPage.addCollectionEnvironmentVariable({ + id: '', + name: faker.string.alpha(8), + value: faker.string.alpha(8), + type: EnvironmentKvPairDataType.STRING, + }); + + await cloudSyncPage.openCommitDialog(); + const editedChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + const rowWarnings: Record = {}; + for (const name of editedChangeNames) { + await cloudSyncCommitDialogPage.openChangeDiff(name); + rowWarnings[name] = { + unknownFields: await cloudSyncCommitDialogPage.getUnknownFieldsIconTooltip(name), + unsupported: await cloudSyncCommitDialogPage.getUnsupportedItemIconTooltip(name), + banner: await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + error: await cloudSyncCommitDialogPage.getUnsupportedDocDiffErrorText(), + }; + } + await cloudSyncCommitDialogPage.closeCommitDialog(); + for (const name of editedChangeNames) { + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.commit(name, faker.lorem.sentence()); + } + + expect(initialChangeNames).toEqual( + expect.arrayContaining([folder.name, ...renamedRequests.map((request) => request.name), 'Base Environment']), + ); + expect(editedChangeNames).toEqual( + expect.arrayContaining([...renamedNames, renamedCollectionName, folder.name, 'Base Environment']), + ); + expect(Object.values(rowWarnings)).toEqual( + editedChangeNames.map(() => ({ unknownFields: '', unsupported: '', banner: false, error: '' })), + ); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-spec-and-unit-tests-show-no-warnings.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-spec-and-unit-tests-show-no-warnings.spec.ts new file mode 100644 index 0000000000..46ae4298a0 --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/clean-spec-and-unit-tests-show-no-warnings.spec.ts @@ -0,0 +1,47 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, test } from "../../../misc/fixtures"; +import { Collection, Info, Specification } from '../../../models/collection'; +import { commitEachChange, NO_ROW_WARNINGS, readRowWarnings } from './helpers'; + +const buildSpec = () => + new Specification(new Info(faker.company.name(), faker.system.semver(), faker.lorem.sentence()), { + [`/${faker.word.sample()}`]: { + get: { operationId: faker.string.alpha(8), responses: { '200': { description: 'OK' } } }, + }, + }); + +test('Verify a Cloud Sync collection with an API spec and unit tests committed and then edited shows no unknown fields or unsupported warnings and commits without confirmation', async ({ + user, +}) => { + const { workspaceFlow } = user.flowManager; + const { cloudSyncPage, specPage, cloudSyncCommitDialogPage } = user.pageManager; + + const project = await workspaceFlow.getProject({ name: 'Personal Workspace' }); + const collection = await workspaceFlow.createCollection(project!, new Collection(faker.string.alphanumeric(10), buildSpec())); + const suiteName = faker.string.alphanumeric(10); + const testName = faker.string.alphanumeric(10); + await workspaceFlow.createTestSuite(collection, suiteName); + const suite = await workspaceFlow.getTestSuite(collection, suiteName); + await workspaceFlow.createUnitTest(suite, testName); + await cloudSyncPage.openCommitDialog(); + const initialChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, initialChangeNames); + + await workspaceFlow.fillSpecification(collection, buildSpec()); + await workspaceFlow.getTestSuite(collection, suiteName); + await specPage.renameUnitTest(testName, faker.string.alphanumeric(10)); + await specPage.renameTestSuite(suiteName, faker.string.alphanumeric(10)); + await cloudSyncPage.openCommitDialog(); + const editedChangeNames = await cloudSyncCommitDialogPage.getChangeNames(); + const rowWarnings = await readRowWarnings(cloudSyncCommitDialogPage, editedChangeNames); + await cloudSyncCommitDialogPage.closeCommitDialog(); + await commitEachChange(cloudSyncPage, cloudSyncCommitDialogPage, editedChangeNames); + + expect(initialChangeNames.length).toBeGreaterThan(0); + expect(editedChangeNames.length).toBeGreaterThan(1); + expect(rowWarnings).toEqual(editedChangeNames.map(() => NO_ROW_WARNINGS)); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/helpers.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/helpers.ts new file mode 100644 index 0000000000..1a36d8c9b8 --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/helpers.ts @@ -0,0 +1,98 @@ +import { faker } from "@faker-js/faker"; + +import type { CloudSyncPage } from "../../../pages/cloud-sync.page"; +import type { CloudSyncCommitDialogPage } from "../../../pages/cloud-sync-commit-dialog.page"; + +export function buildCloudSyncDocs( + collectionName: string, + requestExtra: Record, + extras: { workspace?: Record; environment?: Record } = {}, +) { + const suffix = faker.string.alphanumeric({ length: 24, casing: "lower" }); + const workspaceId = `wrk_${suffix}`; + const requestName = faker.string.alpha(10); + const docs = [ + { + _id: workspaceId, + created: 1_769_407_477_819, + description: "", + name: collectionName, + parentId: null, + scope: "collection", + type: "Workspace", + ...extras.workspace, + }, + { + _id: `env_${suffix}`, + color: null, + created: 1_769_407_477_820, + data: {}, + environmentType: "kv", + isPrivate: false, + metaSortKey: 1_769_407_477_820, + name: "Base Environment", + parentId: workspaceId, + type: "Environment", + ...extras.environment, + }, + { + _id: `req_${suffix}`, + authentication: {}, + body: {}, + created: 1_769_407_553_323, + description: "", + headers: [], + isPrivate: false, + metaSortKey: -1_769_407_553_323, + method: "GET", + name: requestName, + parameters: [], + parentId: workspaceId, + pathParameters: [], + settingDisableRenderRequestBody: false, + settingEncodeUrl: true, + settingFollowRedirects: "global", + settingRebuildPath: true, + settingSendCookies: true, + settingStoreCookies: true, + type: "Request", + url: "", + ...requestExtra, + }, + ]; + return { docs, requestName }; +} + +export interface RowWarnings { + unknownFields: string; + unsupported: string; + banner: boolean; + error: string; +} + +export async function commitEachChange( + cloudSyncPage: CloudSyncPage, + cloudSyncCommitDialogPage: CloudSyncCommitDialogPage, + names: string[], +) { + for (const name of names) { + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.commit(name, faker.lorem.sentence()); + } +} + +export async function readRowWarnings(cloudSyncCommitDialogPage: CloudSyncCommitDialogPage, names: string[]) { + const warnings: RowWarnings[] = []; + for (const name of names) { + await cloudSyncCommitDialogPage.openChangeDiff(name); + warnings.push({ + unknownFields: await cloudSyncCommitDialogPage.getUnknownFieldsIconTooltip(name), + unsupported: await cloudSyncCommitDialogPage.getUnsupportedItemIconTooltip(name), + banner: await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + error: await cloudSyncCommitDialogPage.getUnsupportedDocDiffErrorText(), + }); + } + return warnings; +} + +export const NO_ROW_WARNINGS: RowWarnings = { unknownFields: "", unsupported: "", banner: false, error: "" }; diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/incompatible-remote-document.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/incompatible-remote-document.spec.ts new file mode 100644 index 0000000000..f856ef159e --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/incompatible-remote-document.spec.ts @@ -0,0 +1,34 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, test } from "../../../misc/fixtures"; +import { seedCloudSyncProject } from "../support/cloud-sync-mock"; +import { buildCloudSyncDocs } from './helpers'; + +test('Verify a Cloud Sync document whose remote version is incompatible shows the error icon and banner and can still be committed', async ({ user }) => { + const { workspaceFlow, httpRequestFlow, cloudSyncFlow } = user.flowManager; + const { cloudSyncPage, cloudSyncCommitDialogPage } = user.pageManager; + + const collectionName = faker.string.alpha(10); + const { docs, requestName } = buildCloudSyncDocs(collectionName, { settingEncodeUrl: faker.string.alpha(5) }); + await seedCloudSyncProject(collectionName, docs); + await user.page.reload(); + await cloudSyncFlow.fetch(collectionName); + const collection = await workspaceFlow.getCollection({ name: collectionName }); + const request = await httpRequestFlow.get(requestName); + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameRequest(request!, renamed); + + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.openChangeDiff(renamed); + const iconTooltip = await cloudSyncCommitDialogPage.getUnsupportedItemIconTooltip(renamed); + const bannerText = await cloudSyncCommitDialogPage.getUnsupportedDocDiffErrorText(); + const hasUnknownFieldsBanner = await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + await cloudSyncCommitDialogPage.commit(renamed, faker.lorem.sentence()); + + expect(iconTooltip).toContain('not supported by this version of Insomnia'); + expect(iconTooltip).toContain('converted to an older format'); + expect(bannerText).toContain('committing changes will break functionality for your collaborators'); + expect(hasUnknownFieldsBanner).toBe(false); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-document-with-unknown-fields.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-document-with-unknown-fields.spec.ts new file mode 100644 index 0000000000..2088a3020a --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-document-with-unknown-fields.spec.ts @@ -0,0 +1,44 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, test } from "../../../misc/fixtures"; +import { seedCloudSyncProject } from "../support/cloud-sync-mock"; +import { buildCloudSyncDocs } from './helpers'; + +test('Verify a Cloud Sync document whose remote version has unknown fields shows the warning, highlight and commit confirmation', async ({ + user, +}) => { + const { workspaceFlow, httpRequestFlow, cloudSyncFlow } = user.flowManager; + const { cloudSyncPage, cloudSyncCommitDialogPage } = user.pageManager; + + const collectionName = faker.string.alpha(10); + const { docs, requestName } = buildCloudSyncDocs(collectionName, { futureField: faker.string.alpha(10) }); + await seedCloudSyncProject(collectionName, docs); + await user.page.reload(); + await cloudSyncFlow.fetch(collectionName); + const collection = await workspaceFlow.getCollection({ name: collectionName }); + const request = await httpRequestFlow.get(requestName); + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameRequest(request!, renamed); + + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.openChangeDiff(renamed); + const iconTooltip = await cloudSyncCommitDialogPage.getUnknownFieldsIconTooltip(renamed); + const hasBanner = await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await cloudSyncCommitDialogPage.getUnknownFieldHighlightCount(); + await cloudSyncCommitDialogPage.submitCommit(renamed, faker.lorem.sentence()); + const confirmationText = await cloudSyncCommitDialogPage.getUnknownFieldsConfirmationText(); + await cloudSyncCommitDialogPage.cancelUnknownFieldsConfirmation(); + const isStagedAfterCancel = await cloudSyncCommitDialogPage.isChangeStaged(renamed); + await cloudSyncCommitDialogPage.clickCommit(); + const isConfirmationShownAgain = await cloudSyncCommitDialogPage.isUnknownFieldsConfirmationVisible(); + await cloudSyncCommitDialogPage.confirmUnknownFieldsCommit(renamed); + + expect(iconTooltip).toContain('fields not recognized by this version of Insomnia'); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThan(0); + expect(confirmationText).toContain('may break or remove functionality for your collaborators'); + expect(isStagedAfterCancel).toBe(true); + expect(isConfirmationShownAgain).toBe(true); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-environment-with-unknown-fields.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-environment-with-unknown-fields.spec.ts new file mode 100644 index 0000000000..3e70ce9b53 --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-environment-with-unknown-fields.spec.ts @@ -0,0 +1,50 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, test } from "../../../misc/fixtures"; +import { EnvironmentKvPairDataType } from '../../../models/environment'; +import { seedCloudSyncProject } from "../support/cloud-sync-mock"; +import { buildCloudSyncDocs } from './helpers'; + +test('Verify a Cloud Sync environment whose remote version has unknown fields shows the warning, highlight and commit confirmation', async ({ + user, +}) => { + const { workspaceFlow, cloudSyncFlow } = user.flowManager; + const { cloudSyncPage, environmentPage, cloudSyncCommitDialogPage } = user.pageManager; + + const collectionName = faker.string.alpha(10); + const { docs } = buildCloudSyncDocs(collectionName, {}, { environment: { futureField: faker.string.alpha(10) } }); + await seedCloudSyncProject(collectionName, docs); + await user.page.reload(); + await cloudSyncFlow.fetch(collectionName); + await workspaceFlow.getCollection({ name: collectionName }); + await environmentPage.openCollectionEnvironmentEditor(); + await environmentPage.addCollectionEnvironmentVariable({ + id: '', + name: faker.string.alpha(8), + value: faker.string.alpha(8), + type: EnvironmentKvPairDataType.STRING, + }); + + const environmentRow = 'Base Environment'; + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.openChangeDiff(environmentRow); + const iconTooltip = await cloudSyncCommitDialogPage.getUnknownFieldsIconTooltip(environmentRow); + const hasBanner = await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await cloudSyncCommitDialogPage.getUnknownFieldHighlightCount(); + await cloudSyncCommitDialogPage.submitCommit(environmentRow, faker.lorem.sentence()); + const confirmationText = await cloudSyncCommitDialogPage.getUnknownFieldsConfirmationText(); + await cloudSyncCommitDialogPage.cancelUnknownFieldsConfirmation(); + const isStagedAfterCancel = await cloudSyncCommitDialogPage.isChangeStaged(environmentRow); + await cloudSyncCommitDialogPage.clickCommit(); + const isConfirmationShownAgain = await cloudSyncCommitDialogPage.isUnknownFieldsConfirmationVisible(); + await cloudSyncCommitDialogPage.confirmUnknownFieldsCommit(environmentRow); + + expect(iconTooltip).toContain('fields not recognized by this version of Insomnia'); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThan(0); + expect(confirmationText).toContain('may break or remove functionality for your collaborators'); + expect(isStagedAfterCancel).toBe(true); + expect(isConfirmationShownAgain).toBe(true); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-workspace-with-unknown-fields.spec.ts b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-workspace-with-unknown-fields.spec.ts new file mode 100644 index 0000000000..bee97432de --- /dev/null +++ b/packages/insomnia-test/tests/cloud-sync/unknown-fields/remote-workspace-with-unknown-fields.spec.ts @@ -0,0 +1,43 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { DEFAULT_TIMEOUT, test } from "../../../misc/fixtures"; +import { seedCloudSyncProject } from "../support/cloud-sync-mock"; +import { buildCloudSyncDocs } from './helpers'; + +test('Verify a Cloud Sync workspace whose remote version has unknown fields shows the warning, highlight and commit confirmation', async ({ + user, +}) => { + const { workspaceFlow, cloudSyncFlow } = user.flowManager; + const { cloudSyncPage, cloudSyncCommitDialogPage } = user.pageManager; + + const collectionName = faker.string.alpha(10); + const { docs } = buildCloudSyncDocs(collectionName, {}, { workspace: { futureField: faker.string.alpha(10) } }); + await seedCloudSyncProject(collectionName, docs); + await user.page.reload(); + await cloudSyncFlow.fetch(collectionName); + const collection = await workspaceFlow.getCollection({ name: collectionName }); + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameCollection(collection!, renamed); + + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.openChangeDiff(renamed); + const iconTooltip = await cloudSyncCommitDialogPage.getUnknownFieldsIconTooltip(renamed); + const hasBanner = await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await cloudSyncCommitDialogPage.getUnknownFieldHighlightCount(); + await cloudSyncCommitDialogPage.submitCommit(renamed, faker.lorem.sentence()); + const confirmationText = await cloudSyncCommitDialogPage.getUnknownFieldsConfirmationText(); + await cloudSyncCommitDialogPage.cancelUnknownFieldsConfirmation(); + const isStagedAfterCancel = await cloudSyncCommitDialogPage.isChangeStaged(renamed); + await cloudSyncCommitDialogPage.clickCommit(); + const isConfirmationShownAgain = await cloudSyncCommitDialogPage.isUnknownFieldsConfirmationVisible(); + await cloudSyncCommitDialogPage.confirmUnknownFieldsCommit(renamed); + + expect(iconTooltip).toContain('fields not recognized by this version of Insomnia'); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThan(0); + expect(confirmationText).toContain('may break or remove functionality for your collaborators'); + expect(isStagedAfterCancel).toBe(true); + expect(isConfirmationShownAgain).toBe(true); + await expect.poll(() => cloudSyncPage.isCommitDisabled(), { timeout: DEFAULT_TIMEOUT }).toBe(true); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/body-type-edit-on-reordered-file-is-not-flagged.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/body-type-edit-on-reordered-file-is-not-flagged.spec.ts new file mode 100644 index 0000000000..a39a2fc76f --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/body-type-edit-on-reordered-file-is-not-flagged.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ContentType } from '../../../enums/content-type'; +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from './helpers'; + +test('Verify a body type edit on a reordered file is not flagged as only reformatting', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildReorderedCollectionFile(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await httpRequestFlow.get(requestName); + await httpRequestPage.setBody({ mimeType: ContentType.JSON }); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasDiffWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.openDiscardConfirmation(fileName); + const hasDiscardDialogWarning = await gitCommitDialogPage.isUndiscardableWarningVisibleInDiscardDialog(); + + expect(hasDiffWarning).toBe(false); + expect(hasDiscardDialogWarning).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/commit-clears-change.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/commit-clears-change.spec.ts new file mode 100644 index 0000000000..f0d795ff2f --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/commit-clears-change.spec.ts @@ -0,0 +1,39 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; + +import { ProjectType } from "../../../enums/project-types"; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from "../../../models/project"; +import { buildReorderedCollectionFile } from "./helpers"; + +test("Verify committing a hand-reordered file once leaves the working tree clean", async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ + path: fileName, + content: buildReorderedCollectionFile(faker.string.alpha(10)).content, + }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + const message = faker.string.alphanumeric(10); + const commit = await gitSyncFlow.commit(message); + + await expect + .poll(async () => gitSyncPage.hasUncommittedChanges(), { + timeout: DEFAULT_TIMEOUT, + }) + .toBe(false); + expect(commit.message).toBe(message); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/confirming-discard-brings-change-back.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/confirming-discard-brings-change-back.spec.ts new file mode 100644 index 0000000000..ff5fb10dbb --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/confirming-discard-brings-change-back.spec.ts @@ -0,0 +1,36 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from './helpers'; + +test('Verify confirming discard on a hand-reordered file brings the same change straight back', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ + path: fileName, + content: buildReorderedCollectionFile(faker.string.alpha(10)).content, + }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncFlow.discardChangeAfterReview(fileName); + + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + expect(await gitCommitDialogPage.isUndiscardableWarningVisible()).toBe(true); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/discard-all-confirmation-warns-for-open-file.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/discard-all-confirmation-warns-for-open-file.spec.ts new file mode 100644 index 0000000000..45126443f5 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/discard-all-confirmation-warns-for-open-file.spec.ts @@ -0,0 +1,29 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile, UNDISCARDABLE_WARNING_TEXT } from './helpers'; + +test('Verify the Discard all confirmation warns when the open file is an undiscardable change', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ path: fileName, content: buildReorderedCollectionFile(faker.string.alpha(10)).content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + await gitCommitDialogPage.openDiscardAllConfirmation(); + + expect(await gitCommitDialogPage.isUndiscardableWarningVisibleInDiscardDialog()).toBe(true); + expect(await gitCommitDialogPage.getDiscardConfirmationText()).toContain(UNDISCARDABLE_WARNING_TEXT); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/each-kind-of-real-edit-is-not-flagged.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/each-kind-of-real-edit-is-not-flagged.spec.ts new file mode 100644 index 0000000000..a9f59147f0 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/each-kind-of-real-edit-is-not-flagged.spec.ts @@ -0,0 +1,72 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; + +import { GitChangeList } from "../../../enums/git-change-list"; +import { HttpMethod } from "../../../enums/http-method"; +import { ProjectType } from "../../../enums/project-types"; +import { HTTP_SERVER } from "../../../misc/constants"; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from "../../../models/collection"; +import { Project } from "../../../models/project"; +import { buildReorderedCollectionFile } from "./helpers"; + +test("Verify each kind of real edit on a reordered file is not flagged as only reformatting", async ({ + user, +}) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = + user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildReorderedCollectionFile(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + const request = await httpRequestFlow.get( + requestName, + ); + const edits = [ + () => workspaceFlow.renameRequest(request!, faker.string.alphanumeric(10)), + () => httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alpha(10)}`), + () => httpRequestPage.setMethod(HttpMethod.Post), + () => + httpRequestPage.setHeaders([ + { name: faker.string.alpha(8), value: faker.string.alpha(8) }, + ]), + ]; + const warningsPerEdit: boolean[] = []; + for (const edit of edits) { + await edit(); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + warningsPerEdit.push( + await gitCommitDialogPage.isUndiscardableWarningVisible(), + ); + await gitCommitDialogPage.closeCommitDialog(); + await gitSyncFlow.discardChangeAfterReview(fileName); + await expect + .poll( + async () => { + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasWarning = + await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.closeCommitDialog(); + return hasWarning; + }, + { timeout: DEFAULT_TIMEOUT }, + ) + .toBe(true); + } + + expect(warningsPerEdit).toEqual(edits.map(() => false)); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/helpers.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/helpers.ts new file mode 100644 index 0000000000..b1ac636784 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/helpers.ts @@ -0,0 +1,60 @@ +import { faker } from "@faker-js/faker"; + +export const UNDISCARDABLE_WARNING_TEXT = + "This change is only Insomnia internally reformatting the file"; + +export function buildReorderedCollectionFile( + collectionName: string, + options: { oldSchema?: boolean } = {}, +) { + const id = faker.string.alphanumeric({ length: 16, casing: "lower" }); + const folders = [ + { id: `fld_b${id}`, name: faker.string.alpha(10), created: 3000 }, + { id: `fld_a${id}`, name: faker.string.alpha(10), created: 2000 }, + ]; + // Keep folders in canonical order so an old-schema file differs only by schema normalization. + if (options.oldSchema) folders.reverse(); + const content = [ + "type: collection.insomnia.rest/5.0", + ...(options.oldSchema ? [] : ['schema_version: "5.1"']), + `name: ${collectionName}`, + "meta:", + ` id: wrk_${id}`, + " created: 1000", + " modified: 1000", + "collection:", + ...folders.flatMap((folder) => [ + ` - name: ${folder.name}`, + " meta:", + ` id: ${folder.id}`, + ` created: ${folder.created}`, + ` modified: ${folder.created}`, + " children:", + ` - url: https://example.com/${folder.id}`, + ` name: Request in ${folder.name}`, + " meta:", + ` id: req_${folder.id}`, + " created: 5000", + " modified: 5000", + " method: GET", + " headers:", + " - name: X-Folder", + ` value: ${folder.id}`, + ...(options.oldSchema ? [" id: pair_1"] : []), + ]), + "cookieJar:", + " name: Default Jar", + " meta:", + ` id: jar_${id}`, + " created: 1000", + " modified: 1000", + "environments:", + " name: Base Environment", + " meta:", + ` id: env_${id}`, + " created: 1000", + " modified: 1000", + "", + ].join("\n"); + return { content, requestName: `Request in ${folders[0].name}` }; +} diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/mixed-change-is-not-flagged.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/mixed-change-is-not-flagged.spec.ts new file mode 100644 index 0000000000..aec20390ae --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/mixed-change-is-not-flagged.spec.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from './helpers'; + +test('Verify a real edit mixed with a reorder is not flagged until the real edit is discarded', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildReorderedCollectionFile(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const collection = new Collection(collectionName); + const request = await httpRequestFlow.get(requestName); + await workspaceFlow.renameRequest(request!, faker.string.alphanumeric(10)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const warningWithMixedChange = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.closeCommitDialog(); + await gitSyncFlow.discardChangeAfterReview(fileName); + + expect(warningWithMixedChange).toBe(false); + await expect + .poll( + async () => { + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.closeCommitDialog(); + return hasWarning; + }, + { timeout: DEFAULT_TIMEOUT }, + ) + .toBe(true); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/non-insomnia-added-and-deleted-files-show-no-warning.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/non-insomnia-added-and-deleted-files-show-no-warning.spec.ts new file mode 100644 index 0000000000..482f2e45b3 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/non-insomnia-added-and-deleted-files-show-no-warning.spec.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; + +test('Verify a modified non-Insomnia file and an added or deleted file show no undiscardable warning or unknown fields UI, and commit without confirmation', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const otherFileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ path: otherFileName, content: `openapi: 3.0.0\ninfo:\n title: ${faker.string.alpha(10)}\n` }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-undiscardable-')); + const projectName = faker.string.alphanumeric(10); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(projectName, ProjectType.Git), + GIT_CREDENTIAL.name, + { cloneParentDir }, + ); + const project = (await workspaceFlow.getProject(projectName))!; + const deletedFile = faker.string.alpha(10).toLowerCase(); + const deletedCollection = await workspaceFlow.createCollection( + project, + new Collection(faker.string.alphanumeric(10)), + deletedFile, + ); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.appendFileSync(path.join(cloneDir, otherFileName), ` version: ${faker.string.numeric(3)}\n`); + const addedFile = faker.string.alpha(11).toLowerCase(); + await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), addedFile); + await workspaceFlow.delete(deletedCollection); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { + timeout: DEFAULT_TIMEOUT, + }) + .toBeGreaterThanOrEqual(3); + const unstaged = (await gitSyncFlow.getChanges()).unstaged; + const warnings = []; + const unknownFieldsStates = []; + for (const file of unstaged) { + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, file); + warnings.push(await gitCommitDialogPage.isUndiscardableWarningVisible()); + unknownFieldsStates.push({ + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlightCount: await gitCommitDialogPage.getUnknownFieldHighlightCount(), + }); + await gitCommitDialogPage.closeCommitDialog(); + } + const message = faker.string.alphanumeric(10); + const commit = await gitSyncFlow.commit(message); + + expect(unknownFieldFiles).toEqual([]); + expect(unstaged).toEqual( + expect.arrayContaining([ + expect.stringContaining(otherFileName), + expect.stringContaining(addedFile), + expect.stringContaining(deletedFile), + ]), + ); + expect(warnings).toEqual(unstaged.map(() => false)); + expect(unknownFieldsStates).toEqual(unstaged.map(() => ({ hasBanner: false, highlightCount: 0 }))); + expect(commit.message).toBe(message); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/real-edit-shows-no-warning.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/real-edit-shows-no-warning.spec.ts new file mode 100644 index 0000000000..0cb85c36bf --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/real-edit-shows-no-warning.spec.ts @@ -0,0 +1,43 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/constants'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; + +test('Verify a real edit shows no undiscardable warning in the diff or the discard confirmation', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + const fileName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), fileName); + const request = await httpRequestFlow.create(collection, { + name: faker.string.alphanumeric(10), + method: HttpMethod.Get, + url: `${HTTP_SERVER}/cookies`, + }); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + await workspaceFlow.renameRequest(request, faker.string.alphanumeric(10)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { + timeout: DEFAULT_TIMEOUT, + }) + .toBeGreaterThan(0); + const unstaged = (await gitSyncFlow.getChanges()).unstaged[0]; + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, unstaged); + expect(await gitCommitDialogPage.isUndiscardableWarningVisible()).toBe(false); + + await gitCommitDialogPage.openDiscardConfirmation(unstaged); + expect(await gitCommitDialogPage.isUndiscardableWarningVisibleInDiscardDialog()).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/staged-file-shows-no-warning.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/staged-file-shows-no-warning.spec.ts new file mode 100644 index 0000000000..3620f0725b --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/staged-file-shows-no-warning.spec.ts @@ -0,0 +1,58 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from './helpers'; + +test('Verify a staged hand-reordered file shows no undiscardable warning, including for a real edit made after staging', async ({ + user, +}) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildReorderedCollectionFile(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncFlow.stageChanges([fileName]); + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Staged, fileName); + const hasStagedWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.closeCommitDialog(); + + const request = await httpRequestFlow.get(requestName); + await workspaceFlow.renameRequest(request!, faker.string.alphanumeric(10)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasUnstagedWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.openDiscardConfirmation(fileName); + const hasDiscardDialogWarning = await gitCommitDialogPage.isUndiscardableWarningVisibleInDiscardDialog(); + await gitCommitDialogPage.confirmDiscard(); + + expect(hasStagedWarning).toBe(false); + expect(hasUnstagedWarning).toBe(false); + expect(hasDiscardDialogWarning).toBe(false); + await expect + .poll(async () => (await gitCommitDialogPage.getUnstagedChanges()).includes(fileName), { + timeout: DEFAULT_TIMEOUT, + }) + .toBe(false); + expect(await gitCommitDialogPage.getStagedChanges()).toContain(fileName); +}); diff --git a/packages/insomnia-test/tests/git-sync/undiscardable-change/warning-in-diff-and-discard-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/undiscardable-change/warning-in-diff-and-discard-confirmation.spec.ts new file mode 100644 index 0000000000..562ef6e3d8 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/undiscardable-change/warning-in-diff-and-discard-confirmation.spec.ts @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile, UNDISCARDABLE_WARNING_TEXT } from './helpers'; + +const variants = [ + { title: 'hand-reordered file', oldSchema: false }, + { title: 'file from an older schema that Insomnia only normalizes', oldSchema: true }, +]; + +for (const { title, oldSchema } of variants) { + test(`Verify a ${title} warns that the change cannot be discarded in both the diff and the discard confirmation`, async ({ + user, + }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ + path: fileName, + content: buildReorderedCollectionFile(faker.string.alpha(10), { oldSchema }).content, + }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const { unknownFieldFiles, unknownFieldsWarningText } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { + timeout: DEFAULT_TIMEOUT, + }) + .toContain(fileName); + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasDiffWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + const hasUnknownFieldsBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const unknownFieldHighlightCount = await gitCommitDialogPage.getUnknownFieldHighlightCount(); + await gitCommitDialogPage.openDiscardConfirmation(fileName); + + expect(unknownFieldFiles).toEqual([]); + expect(unknownFieldsWarningText).toBe(''); + expect(hasDiffWarning).toBe(true); + expect(hasUnknownFieldsBanner).toBe(false); + expect(unknownFieldHighlightCount).toBe(0); + expect(await gitCommitDialogPage.isUndiscardableWarningVisibleInDiscardDialog()).toBe(true); + expect(await gitCommitDialogPage.getDiscardConfirmationText()).toContain(UNDISCARDABLE_WARNING_TEXT); + }); +} diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/anchored-unknown-field-shows-banner-highlight-and-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/anchored-unknown-field-shows-banner-highlight-and-confirmation.spec.ts new file mode 100644 index 0000000000..fc0b759ec4 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/anchored-unknown-field-shows-banner-highlight-and-confirmation.spec.ts @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify an unknown field defined with a YAML anchor and alias is listed on Create Project, shows the banner and highlight, and asks for confirmation on commit', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content } = buildCollectionFileWithUnknownField(faker.string.alpha(10), { anchoredUnknownField: true }); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + await gitSyncFlow.stageChanges([fileName]); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Staged, fileName); + const hasBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await gitCommitDialogPage.getUnknownFieldHighlightCount(); + await gitCommitDialogPage.commitStaged(faker.lorem.sentence()); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + + expect(unknownFieldFiles).toEqual([fileName]); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThan(0); + expect(confirmationText).toContain('Unrecognized Fields'); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/cloud-sync-and-git-sync-warnings-stay-independent.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/cloud-sync-and-git-sync-warnings-stay-independent.spec.ts new file mode 100644 index 0000000000..e8278664e1 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/cloud-sync-and-git-sync-warnings-stay-independent.spec.ts @@ -0,0 +1,66 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { seedCloudSyncProject } from '../../cloud-sync/support/cloud-sync-mock'; +import { buildCloudSyncDocs } from '../../cloud-sync/unknown-fields/helpers'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify Cloud Sync and Git Sync unknown fields warnings stay independent in one session', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow, cloudSyncFlow } = user.flowManager; + const { gitSyncPage, cloudSyncPage, cloudSyncCommitDialogPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const gitCollectionName = faker.string.alpha(10); + const { content, requestName: gitRequestName } = buildCollectionFileWithUnknownField(gitCollectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const gitBefore = { + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlighted: (await gitCommitDialogPage.getUnknownFieldHighlightCount()) > 0, + }; + await gitCommitDialogPage.closeCommitDialog(); + + const cloudCollectionName = faker.string.alpha(10); + const { docs, requestName } = buildCloudSyncDocs(cloudCollectionName, { futureField: faker.string.alpha(10) }); + await seedCloudSyncProject(cloudCollectionName, docs); + await user.page.reload(); + await cloudSyncFlow.fetch(cloudCollectionName); + const cloudCollection = await workspaceFlow.getCollection({ name: cloudCollectionName }); + const request = await httpRequestFlow.get(requestName); + const renamed = faker.string.alphanumeric(10); + await workspaceFlow.renameRequest(request!, renamed); + await cloudSyncPage.openCommitDialog(); + await cloudSyncCommitDialogPage.openChangeDiff(renamed); + const cloudState = { + hasBanner: await cloudSyncCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlighted: (await cloudSyncCommitDialogPage.getUnknownFieldHighlightCount()) > 0, + }; + await cloudSyncCommitDialogPage.submitCommit(renamed, faker.lorem.sentence()); + const cloudConfirmationText = await cloudSyncCommitDialogPage.getUnknownFieldsConfirmationText(); + await cloudSyncCommitDialogPage.confirmUnknownFieldsCommit(renamed); + + await httpRequestFlow.get(gitRequestName); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const gitAfter = { + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlighted: (await gitCommitDialogPage.getUnknownFieldHighlightCount()) > 0, + hasUndiscardableWarning: await gitCommitDialogPage.isUndiscardableWarningVisible(), + }; + + expect(gitBefore).toEqual({ hasBanner: true, highlighted: true }); + expect(cloudState).toEqual({ hasBanner: true, highlighted: true }); + expect(cloudConfirmationText).toContain('may break or remove functionality for your collaborators'); + expect(gitAfter).toEqual({ hasBanner: true, highlighted: true, hasUndiscardableWarning: false }); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/commit-and-push-requires-unknown-fields-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/commit-and-push-requires-unknown-fields-confirmation.spec.ts new file mode 100644 index 0000000000..1c88373fc2 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/commit-and-push-requires-unknown-fields-confirmation.spec.ts @@ -0,0 +1,74 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, getServerCommits, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify Commit and push on a staged file with unknown fields asks for confirmation, pushes nothing on Cancel and pushes a commit without the unknown field after Continue', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content, unknownField } = buildCollectionFileWithUnknownField(faker.string.alpha(10)); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const localCommitCountBefore = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + const serverCommitsBefore = await getServerCommits(); + await gitSyncFlow.stageChanges([fileName]); + const message = faker.lorem.sentence(); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.commitStagedAndPush(message); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + await gitCommitDialogPage.cancelUnknownFieldsConfirmation(); + const stagedAfterCancel = await gitCommitDialogPage.getStagedChanges(); + const localCommitCountAfterCancel = Number( + execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' }), + ); + const serverCommitsAfterCancel = await getServerCommits(); + await gitCommitDialogPage.commitStagedAndPush(message); + await gitCommitDialogPage.confirmUnknownFieldsCommit(); + await gitCommitDialogPage.waitForCommitToFinish(); + await expect + .poll(async () => (await getServerCommits()).length, { timeout: DEFAULT_TIMEOUT }) + .toBe(serverCommitsBefore.length + 1); + const serverCommitsAfterConfirm = await getServerCommits(); + const localHeadId = execSync('git rev-parse HEAD', { cwd: cloneDir, encoding: 'utf8' }).trim(); + const localCommitCountAfterConfirm = Number( + execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' }), + ); + const committedFile = execSync(`git show HEAD:${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const workingTreeStatus = execSync('git status --porcelain', { cwd: cloneDir, encoding: 'utf8' }); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + { cloneParentDir: fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')) }, + ); + + expect(confirmationText).toContain('Unrecognized Fields'); + expect(stagedAfterCancel).toEqual([fileName]); + expect(localCommitCountAfterCancel).toBe(localCommitCountBefore); + expect(serverCommitsAfterCancel).toEqual(serverCommitsBefore); + expect(localCommitCountAfterConfirm).toBe(localCommitCountBefore + 1); + expect(serverCommitsAfterConfirm[0]).toMatchObject({ id: localHeadId, message }); + expect(committedFile).not.toContain(unknownField); + expect(workingTreeStatus).toBe(''); + expect(unknownFieldFiles).toEqual([]); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/commit-requires-unknown-fields-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/commit-requires-unknown-fields-confirmation.spec.ts new file mode 100644 index 0000000000..85abb0a8f9 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/commit-requires-unknown-fields-confirmation.spec.ts @@ -0,0 +1,72 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { UNDISCARDABLE_WARNING_TEXT } from '../undiscardable-change/helpers'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify committing a staged file with unknown fields asks for confirmation, only commits after Continue and leaves a clean tree and a clone without the unknown field', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content, unknownField } = buildCollectionFileWithUnknownField(faker.string.alpha(10)); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const headFileBefore = execSync(`git show HEAD:${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const localFileBefore = fs.readFileSync(path.join(cloneDir, fileName), 'utf8'); + const commitCountBefore = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncFlow.stageChanges([fileName]); + const message = faker.lorem.sentence(); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Staged, fileName); + const hasStagedUndiscardableWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.commitStaged(message); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + await gitCommitDialogPage.cancelUnknownFieldsConfirmation(); + const stagedAfterCancel = await gitCommitDialogPage.getStagedChanges(); + const commitCountAfterCancel = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitCommitDialogPage.closeCommitDialog(); + const commit = await gitSyncFlow.commitStagedConfirmingUnknownFields(message); + const commitCountAfterConfirm = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncFlow.push(); + const committedFile = execSync(`git show HEAD:${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const workingTreeStatus = execSync('git status --porcelain', { cwd: cloneDir, encoding: 'utf8' }); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + { cloneParentDir: fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')) }, + ); + + expect(headFileBefore).toContain(unknownField); + expect(localFileBefore).not.toContain(unknownField); + expect(hasStagedUndiscardableWarning).toBe(false); + expect(confirmationText).toContain('Unrecognized Fields'); + expect(confirmationText).not.toContain(UNDISCARDABLE_WARNING_TEXT); + expect(stagedAfterCancel).toEqual([fileName]); + expect(commitCountAfterCancel).toBe(commitCountBefore); + expect(commit.message).toBe(message); + expect(commitCountAfterConfirm).toBe(commitCountBefore + 1); + expect(committedFile).not.toContain(unknownField); + expect(workingTreeStatus).toBe(''); + expect(unknownFieldFiles).toEqual([]); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/deeply-nested-unknown-fields-show-banner-highlight-and-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/deeply-nested-unknown-fields-show-banner-highlight-and-confirmation.spec.ts new file mode 100644 index 0000000000..8a6aa4889c --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/deeply-nested-unknown-fields-show-banner-highlight-and-confirmation.spec.ts @@ -0,0 +1,50 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField, type ExtraUnknownField } from './helpers'; + +test('Verify unknown fields nested deep in request settings, folder request meta, environment meta and the file root are listed on Create Project, show the banner and highlights, and ask for confirmation on commit', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const extraUnknownFields: ExtraUnknownField[] = [ + { + target: 'request.settings.cookies', + name: `future${faker.string.alpha(6)}`, + value: { level1: { level2: [{ level3: faker.string.alpha(8), list: [1, 2, 3] }, faker.string.alpha(8)] } }, + }, + { target: 'folder.request.meta', name: `future${faker.string.alpha(6)}` }, + { target: 'environments.meta', name: `future${faker.string.alpha(6)}`, value: { enabled: true, items: [] } }, + { target: 'root', name: `future${faker.string.alpha(6)}`, value: [faker.string.alpha(8), [faker.number.int(100)]] }, + ]; + const { content } = buildCollectionFileWithUnknownField(faker.string.alpha(10), { extraUnknownFields }); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + await gitSyncFlow.stageChanges([fileName]); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Staged, fileName); + const hasBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await gitCommitDialogPage.getUnknownFieldHighlightCount(); + await gitCommitDialogPage.commitStaged(faker.lorem.sentence()); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + + expect(unknownFieldFiles).toEqual([fileName]); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThanOrEqual(extraUnknownFields.length); + expect(confirmationText).toContain('Unrecognized Fields'); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/deleted-file-with-unknown-fields-shows-no-warnings.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/deleted-file-with-unknown-fields-shows-no-warnings.spec.ts new file mode 100644 index 0000000000..7bd57bd4d9 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/deleted-file-with-unknown-fields-shows-no-warnings.spec.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify a deleted file that had unknown fields shows neither the unknown fields UI nor the undiscardable warning', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + await pushServerFile({ path: fileName, content: buildCollectionFileWithUnknownField(faker.string.alpha(10)).content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + fs.rmSync(path.join(cloneDir, fileName)); + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const diffState = { + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlightCount: await gitCommitDialogPage.getUnknownFieldHighlightCount(), + hasUndiscardableWarning: await gitCommitDialogPage.isUndiscardableWarningVisible(), + }; + + expect(diffState).toEqual({ hasBanner: false, highlightCount: 0, hasUndiscardableWarning: false }); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/diff-shows-unknown-fields-instead-of-undiscardable-warning.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/diff-shows-unknown-fields-instead-of-undiscardable-warning.spec.ts new file mode 100644 index 0000000000..34025692e7 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/diff-shows-unknown-fields-instead-of-undiscardable-warning.spec.ts @@ -0,0 +1,57 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify cloning lists the file with unknown fields and the Commit Changes diff shows the unknown fields banner and highlight instead of the undiscardable warning', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, unknownField, unknownValue } = buildCollectionFileWithUnknownField(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + const projectName = faker.string.alphanumeric(10); + const { unknownFieldFiles, unknownFieldsWarningText } = await workspaceFlow.cloneWithScanReport( + new Project(projectName, ProjectType.Git), + GIT_CREDENTIAL.name, + { cloneParentDir }, + ); + const project = await workspaceFlow.getProject(projectName); + const collection = await workspaceFlow.getCollection({ name: collectionName }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const gitDiff = execSync('git diff -U0', { cwd: cloneDir, encoding: 'utf8' }); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const bannerText = await gitCommitDialogPage.getUnknownFieldsDiffWarningText(); + const highlightCount = await gitCommitDialogPage.getUnknownFieldHighlightCount(); + const hasUndiscardableWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + + expect(unknownFieldFiles).toEqual([fileName]); + expect(unknownFieldsWarningText).toContain('Unknown Fields Detected'); + expect(project?.id).toBeTruthy(); + expect(collection).toBeDefined(); + expect(gitDiff).toContain(`- ${unknownField}: ${unknownValue}`); + expect(hasBanner).toBe(true); + expect(bannerText).toContain('may break or remove functionality for your collaborators'); + expect(highlightCount).toBeGreaterThan(0); + expect(hasUndiscardableWarning).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/generated-commits-require-unknown-fields-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/generated-commits-require-unknown-fields-confirmation.spec.ts new file mode 100644 index 0000000000..3c42a36d28 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/generated-commits-require-unknown-fields-confirmation.spec.ts @@ -0,0 +1,64 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ProjectType } from '../../../enums/project-types'; +import { MOCK_LLM_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify committing AI generated commits that include a file with unknown fields asks for confirmation, commits nothing on Cancel and commits without the unknown field after Continue', async ({ + user, +}) => { + test.fail(true, 'INS-4134'); + + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.set({ + aiUrlBackend: { url: MOCK_LLM_SERVER, model: 'mock-llm-model-1', apiKey: faker.string.alphanumeric(24) }, + aiSmartCommits: true, + }); + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content, unknownField } = buildCollectionFileWithUnknownField(faker.string.alpha(10)); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const commitCountBefore = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.generateCommits(); + await gitCommitDialogPage.submitGeneratedCommits(); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + await gitCommitDialogPage.cancelUnknownFieldsConfirmation(); + const commitCountAfterCancel = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitCommitDialogPage.submitGeneratedCommits(); + await gitCommitDialogPage.confirmUnknownFieldsCommit(); + await gitCommitDialogPage.waitForCommitToFinish(); + await expect + .poll(() => Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })), { + timeout: DEFAULT_TIMEOUT, + }) + .toBe(commitCountBefore + 1); + const committedFile = execSync(`git show HEAD:${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const committedSubject = execSync('git log -1 --format=%s', { cwd: cloneDir, encoding: 'utf8' }).trim(); + const workingTreeStatus = execSync('git status --porcelain', { cwd: cloneDir, encoding: 'utf8' }); + + expect(confirmationText).toContain('Unrecognized Fields'); + expect(commitCountAfterCancel).toBe(commitCountBefore); + expect(committedSubject).toContain(fileName); + expect(committedFile).not.toContain(unknownField); + expect(workingTreeStatus).toBe(''); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/helpers.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/helpers.ts new file mode 100644 index 0000000000..54f54934e5 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/helpers.ts @@ -0,0 +1,171 @@ +import { faker } from "@faker-js/faker"; + +export type UnknownFieldValue = string | number | boolean | null | UnknownFieldValue[] | { [key: string]: UnknownFieldValue }; + +// Where in the collection file an extra unknown field is injected. Folder targets apply to every folder. +export type UnknownFieldTarget = + | "root" + | "workspace.meta" + | "request" + | "request.meta" + | "request.settings" + | "request.settings.cookies" + | "folder" + | "folder.meta" + | "folder.request" + | "folder.request.meta" + | "cookieJar" + | "cookieJar.meta" + | "environments" + | "environments.meta"; + +export interface ExtraUnknownField { + target: UnknownFieldTarget; + name: string; + // Defaults to a random string. Objects and arrays are rendered as nested YAML, so the field can be arbitrarily deep. + value?: UnknownFieldValue; +} + +const isScalar = (value: UnknownFieldValue): value is string | number | boolean | null => value === null || typeof value !== "object"; +const isEmpty = (value: UnknownFieldValue) => (Array.isArray(value) ? value.length === 0 : typeof value === "object" && value !== null && Object.keys(value).length === 0); + +function renderYamlValue(value: string | number | boolean | null) { + return typeof value === "string" ? JSON.stringify(value) : String(value); +} + +// Renders `name: value` as YAML lines at the given indent (in spaces), recursing into objects and arrays +function renderYamlField(indent: number, name: string, value: UnknownFieldValue): string[] { + const pad = " ".repeat(indent); + if (isScalar(value)) { + return [`${pad}${name}: ${renderYamlValue(value)}`]; + } + if (isEmpty(value)) { + return [`${pad}${name}: ${Array.isArray(value) ? "[]" : "{}"}`]; + } + return [`${pad}${name}:`, ...renderYamlBody(indent + 2, value)]; +} + +function renderYamlBody(indent: number, value: UnknownFieldValue): string[] { + const pad = " ".repeat(indent); + if (Array.isArray(value)) { + return value.flatMap((item) => { + if (isScalar(item)) { + return [`${pad}- ${renderYamlValue(item)}`]; + } + if (isEmpty(item)) { + return [`${pad}- ${Array.isArray(item) ? "[]" : "{}"}`]; + } + // Render the item one level deeper, then replace the first line's indent with the "- " marker + const [first, ...rest] = renderYamlBody(indent + 2, item); + return [`${pad}- ${first.trimStart()}`, ...rest]; + }); + } + return Object.entries(value as Record).flatMap(([key, child]) => renderYamlField(indent, key, child)); +} + +export function buildCollectionFileWithUnknownField( + collectionName: string, + options: { + requestName?: string; + unknownField?: string; + anchoredUnknownField?: boolean; + omitDefaultUnknownField?: boolean; + extraUnknownFields?: ExtraUnknownField[]; + } = {}, +) { + const id = faker.string.alphanumeric({ length: 16, casing: "lower" }); + const requestName = options.requestName ?? faker.string.alpha(10); + const unknownField = options.unknownField ?? "futureField"; + const unknownValue = faker.string.alpha(10); + const extraUnknownFields = (options.extraUnknownFields ?? []).map((field) => ({ + ...field, + value: field.value ?? faker.string.alpha(10), + })); + // Lines for the extra unknown fields that target `target`, to be appended at the end of that block + const extras = (target: UnknownFieldTarget, indent: number) => + extraUnknownFields.filter((field) => field.target === target).flatMap((field) => renderYamlField(indent, field.name, field.value)); + // Folder B is listed first but was created after Folder A, as if someone reordered the file by hand, + // so the file is also an undiscardable change on top of the unknown field + const folders = [ + { id: `fld_b${id}`, name: faker.string.alpha(10), created: 3000 }, + { id: `fld_a${id}`, name: faker.string.alpha(10), created: 2000 }, + ]; + const content = [ + "type: collection.insomnia.rest/5.0", + 'schema_version: "5.1"', + `name: ${collectionName}`, + "meta:", + ` id: wrk_${id}`, + " created: 1000", + " modified: 1000", + ' description: ""', + ...extras("workspace.meta", 2), + "collection:", + ` - url: https://example.com/${id}`, + ` name: ${requestName}`, + " meta:", + ` id: req_${id}`, + " created: 5000", + " modified: 5000", + " isPrivate: false", + ' description: ""', + " sortKey: 0", + ...extras("request.meta", 6), + " method: GET", + ...extras("request", 4), + ...(options.omitDefaultUnknownField + ? [] + : options.anchoredUnknownField + ? [` ${unknownField}: &${unknownField}Anchor`, ` nested: ${unknownValue}`, ` ${unknownField}Copy: *${unknownField}Anchor`] + : [` ${unknownField}: ${unknownValue}`]), + " settings:", + " renderRequestBody: true", + " encodeUrl: true", + " followRedirects: global", + " cookies:", + " send: true", + " store: true", + ...extras("request.settings.cookies", 8), + " rebuildPath: true", + ...extras("request.settings", 6), + ...folders.flatMap((folder) => [ + ` - name: ${folder.name}`, + " meta:", + ` id: ${folder.id}`, + ` created: ${folder.created}`, + ` modified: ${folder.created}`, + ...extras("folder.meta", 6), + ...extras("folder", 4), + " children:", + ` - url: https://example.com/${folder.id}`, + ` name: Request in ${folder.name}`, + " meta:", + ` id: req_${folder.id}`, + " created: 5000", + " modified: 5000", + ...extras("folder.request.meta", 10), + " method: GET", + ...extras("folder.request", 8), + ]), + "cookieJar:", + " name: Default Jar", + " meta:", + ` id: jar_${id}`, + " created: 1000", + " modified: 1000", + ...extras("cookieJar.meta", 4), + ...extras("cookieJar", 2), + "environments:", + " name: Base Environment", + " meta:", + ` id: env_${id}`, + " created: 1000", + " modified: 1000", + " isPrivate: false", + ...extras("environments.meta", 4), + ...extras("environments", 2), + ...extras("root", 0), + "", + ].join("\n"); + return { content, requestName, unknownField, unknownValue, extraUnknownFields }; +} diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/mixed-real-edit-and-unknown-field-file.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/mixed-real-edit-and-unknown-field-file.spec.ts new file mode 100644 index 0000000000..ed03b5b022 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/mixed-real-edit-and-unknown-field-file.spec.ts @@ -0,0 +1,63 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { UNDISCARDABLE_WARNING_TEXT } from '../undiscardable-change/helpers'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify a real edit mixed with an unknown field shows only the unknown fields UI, and discarding shows no undiscardable warning and brings the unknown fields warning back', async ({ + user, +}) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildCollectionFileWithUnknownField(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const request = await httpRequestFlow.get(requestName); + await workspaceFlow.renameRequest(request!, faker.string.alphanumeric(10)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const mixedState = { + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlighted: (await gitCommitDialogPage.getUnknownFieldHighlightCount()) > 0, + hasUndiscardableWarning: await gitCommitDialogPage.isUndiscardableWarningVisible(), + }; + await gitCommitDialogPage.openDiscardConfirmation(fileName); + const discardDialogText = await gitCommitDialogPage.getDiscardConfirmationText(); + await gitCommitDialogPage.confirmDiscard(); + await gitCommitDialogPage.closeCommitDialog(); + + expect(mixedState).toEqual({ hasBanner: true, highlighted: true, hasUndiscardableWarning: false }); + expect(discardDialogText).not.toContain(UNDISCARDABLE_WARNING_TEXT); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toEqual([fileName]); + await expect + .poll( + async () => { + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const state = { + hasBanner: await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(), + highlighted: (await gitCommitDialogPage.getUnknownFieldHighlightCount()) > 0, + hasUndiscardableWarning: await gitCommitDialogPage.isUndiscardableWarningVisible(), + }; + await gitCommitDialogPage.closeCommitDialog(); + return state; + }, + { timeout: DEFAULT_TIMEOUT }, + ) + .toEqual({ hasBanner: true, highlighted: true, hasUndiscardableWarning: false }); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/restage-after-edit-still-asks-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/restage-after-edit-still-asks-confirmation.spec.ts new file mode 100644 index 0000000000..460e90ea03 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/restage-after-edit-still-asks-confirmation.spec.ts @@ -0,0 +1,67 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify editing a file again after staging it keeps it in both lists, shows no undiscardable warning and still asks for the unknown fields confirmation on commit', async ({ + user, +}) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const collectionName = faker.string.alpha(10); + const { content, requestName } = buildCollectionFileWithUnknownField(collectionName); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const commitCountBefore = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncFlow.stageChanges([fileName]); + const request = await httpRequestFlow.get(requestName); + await workspaceFlow.renameRequest(request!, faker.string.alphanumeric(10)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const message = faker.lorem.sentence(); + await gitSyncPage.openCommitDialog(); + const stagedChanges = await gitCommitDialogPage.getStagedChanges(); + const unstagedChanges = await gitCommitDialogPage.getUnstagedChanges(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const hasUnstagedUndiscardableWarning = await gitCommitDialogPage.isUndiscardableWarningVisible(); + await gitCommitDialogPage.commitStaged(message); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + await gitCommitDialogPage.cancelUnknownFieldsConfirmation(); + const stagedAfterCancel = await gitCommitDialogPage.getStagedChanges(); + const commitCountAfterCancel = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitCommitDialogPage.commitStaged(message); + await gitCommitDialogPage.confirmUnknownFieldsCommit(); + await gitCommitDialogPage.waitForCommitToFinish(); + const commitCountAfterConfirm = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + + expect(stagedChanges).toContain(fileName); + expect(unstagedChanges).toContain(fileName); + expect(hasUnstagedUndiscardableWarning).toBe(false); + expect(confirmationText).toContain('Unrecognized Fields'); + expect(stagedAfterCancel).toContain(fileName); + expect(commitCountAfterCancel).toBe(commitCountBefore); + expect(commitCountAfterConfirm).toBe(commitCountBefore + 1); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/stage-all-prompt-then-cancel-unknown-fields-keeps-nothing-staged.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/stage-all-prompt-then-cancel-unknown-fields-keeps-nothing-staged.spec.ts new file mode 100644 index 0000000000..32ff13aa6c --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/stage-all-prompt-then-cancel-unknown-fields-keeps-nothing-staged.spec.ts @@ -0,0 +1,51 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { UNDISCARDABLE_WARNING_TEXT } from '../undiscardable-change/helpers'; +import { buildCollectionFileWithUnknownField } from './helpers'; + +test('Verify cancelling the unknown fields confirmation after accepting the stage all prompt leaves nothing staged and creates no commit', async ({ + user, +}) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content } = buildCollectionFileWithUnknownField(faker.string.alpha(10)); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-unknown-fields-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { + cloneParentDir, + }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + const commitCountBefore = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.commitStaged(faker.lorem.sentence()); + const stageAllPromptText = await gitCommitDialogPage.getStageAllPromptText(); + await gitCommitDialogPage.acceptStageAllPrompt(); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + await gitCommitDialogPage.cancelUnknownFieldsConfirmation(); + const stagedAfterCancel = await gitCommitDialogPage.getStagedChanges(); + const unstagedAfterCancel = await gitCommitDialogPage.getUnstagedChanges(); + const commitCountAfterCancel = Number(execSync('git rev-list --count HEAD', { cwd: cloneDir, encoding: 'utf8' })); + + expect(stageAllPromptText).toContain('There are no staged changes to commit'); + expect(confirmationText).toContain('Unrecognized Fields'); + expect(confirmationText).not.toContain(UNDISCARDABLE_WARNING_TEXT); + expect(stagedAfterCancel).toEqual([]); + expect(unstagedAfterCancel).toEqual([fileName]); + expect(commitCountAfterCancel).toBe(commitCountBefore); +}); diff --git a/packages/insomnia-test/tests/git-sync/unknown-fields/unknown-field-at-every-location-shows-banner-highlight-and-confirmation.spec.ts b/packages/insomnia-test/tests/git-sync/unknown-fields/unknown-field-at-every-location-shows-banner-highlight-and-confirmation.spec.ts new file mode 100644 index 0000000000..d84da2a2ee --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/unknown-fields/unknown-field-at-every-location-shows-banner-highlight-and-confirmation.spec.ts @@ -0,0 +1,64 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildCollectionFileWithUnknownField, type UnknownFieldTarget } from './helpers'; + +const targets: UnknownFieldTarget[] = [ + 'workspace.meta', + 'request', + 'request.meta', + 'request.settings', + 'folder', + 'folder.meta', + 'folder.request', + 'cookieJar', + 'cookieJar.meta', + 'environments', + 'request.settings.cookies', + 'folder.request.meta', + 'environments.meta', + 'root', +]; + +test.describe('Unknown field at one location of the collection file', () => { + for (const target of targets) { + test(`Verify an unknown field at ${target} is listed on Create Project, shows the banner and highlight, and asks for confirmation on commit`, async ({ + user, + }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content } = buildCollectionFileWithUnknownField(faker.string.alpha(10), { + omitDefaultUnknownField: true, + extraUnknownFields: [{ target, name: `future${faker.string.alpha(6)}` }], + }); + await pushServerFile({ path: fileName, content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const { unknownFieldFiles } = await workspaceFlow.cloneWithScanReport( + new Project(faker.string.alphanumeric(10), ProjectType.Git), + GIT_CREDENTIAL.name, + ); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged, { timeout: DEFAULT_TIMEOUT }) + .toContain(fileName); + + await gitSyncFlow.stageChanges([fileName]); + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Staged, fileName); + const hasBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const highlightCount = await gitCommitDialogPage.getUnknownFieldHighlightCountAcrossDiff(); + await gitCommitDialogPage.commitStaged(faker.lorem.sentence()); + const confirmationText = await gitCommitDialogPage.getUnknownFieldsConfirmationText(); + + expect(unknownFieldFiles).toEqual([fileName]); + expect(hasBanner).toBe(true); + expect(highlightCount).toBeGreaterThan(0); + expect(confirmationText).toContain('Unrecognized Fields'); + }); + } +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/dedicated-card-for-every-entity-type.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/dedicated-card-for-every-entity-type.spec.ts new file mode 100644 index 0000000000..1ccf650c7e --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/dedicated-card-for-every-entity-type.spec.ts @@ -0,0 +1,91 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { planEntityTypeFiles } from './helpers'; + +test('Verify every entity type renders its own card and supports stage, unstage and discard, and partially staging a new mock file keeps its Mock Server URL in the staged content', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const { names, build } = planEntityTypeFiles(); + const files = { + collection: `${faker.string.alpha(8).toLowerCase()}.yaml`, + mock: `${faker.string.alpha(9).toLowerCase()}.yaml`, + mcp: `${faker.string.alpha(10).toLowerCase()}.yaml`, + }; + const original = build(false); + const seedFiles: Record = { + [files.collection]: original.collection, + [files.mock]: original.mock, + [files.mcp]: original.mcp, + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const changed = build(true); + // A second plan has its own ids, so its mock file is a new file next to the three modified ones + const newMock = planEntityTypeFiles(); + const newMockFile = `${faker.string.alpha(11).toLowerCase()}.yaml`; + fs.writeFileSync(path.join(cloneDir, files.collection), changed.collection); + fs.writeFileSync(path.join(cloneDir, files.mock), changed.mock); + fs.writeFileSync(path.join(cloneDir, files.mcp), changed.mcp); + fs.writeFileSync(path.join(cloneDir, newMockFile), newMock.build(true).mock); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(4); + + const entitiesByFile = [ + { file: files.collection, entities: [names.folder, names.request, names.webSocket, names.socketIo, names.grpc, 'Base Environment', 'Default Jar'] }, + { file: files.mock, entities: [names.mockRoute] }, + { file: files.mcp, entities: [names.mcp] }, + ]; + const tabsByEntity: Record = {}; + for (const { file, entities } of entitiesByFile) { + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, file); + for (const entity of entities) { + tabsByEntity[entity] = await gitCommitDialogPage.getEntityCardTabs(entity); + } + } + for (const { file, entities } of entitiesByFile) { + for (const entity of entities) { + await gitSyncFlow.stageEntity(file, entity); + await gitSyncFlow.unstageEntity(file, entity); + await gitSyncFlow.discardEntity(file, entity); + } + } + await expect.poll(async () => gitCommitDialogPage.getUnstagedChanges(), { timeout: DEFAULT_TIMEOUT }).toEqual([newMockFile]); + await gitSyncFlow.stageEntity(newMockFile, 'https://mock.example.com'); + const stagedWithServerOnly = execSync(`git show :${newMockFile}`, { cwd: cloneDir, encoding: 'utf8' }); + await gitSyncFlow.stageEntity(newMockFile, newMock.names.mockRoute); + const stagedWithServerAndRoute = execSync(`git show :${newMockFile}`, { cwd: cloneDir, encoding: 'utf8' }); + await gitCommitDialogPage.closeCommitDialog(); + + expect(tabsByEntity).toEqual({ + [names.folder]: ['Headers 1'], + [names.request]: ['Headers 1'], + [names.webSocket]: ['Headers 1'], + [names.socketIo]: ['Headers 1'], + [names.grpc]: ['Headers 1'], + 'Base Environment': ['Variables 1'], + 'Default Jar': ['Cookies 1'], + [names.mockRoute]: ['Headers 1'], + [names.mcp]: ['Headers 1'], + }); + expect(stagedWithServerOnly).toContain('https://mock.example.com'); + expect(stagedWithServerOnly).not.toContain(newMock.names.mockRoute); + expect(stagedWithServerAndRoute).toContain('https://mock.example.com'); + expect(stagedWithServerAndRoute).toContain(newMock.names.mockRoute); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/discard-environment-variable-change.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/discard-environment-variable-change.spec.ts new file mode 100644 index 0000000000..3337f82b00 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/discard-environment-variable-change.spec.ts @@ -0,0 +1,78 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { ENVIRONMENT_TYPE, EnvironmentKvPairDataType } from '../../../models/environment'; +import { Project } from '../../../models/project'; + +test('Verify discarding an environment card reverts its variable changes and leaves other environments alone', async ({ user }) => { + const { cookieFlow, environmentFlow, gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { cookiePage, environmentPage, gitSyncPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const collectionBaseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), collectionBaseName); + const baseName = faker.string.alpha(10); + const subName = faker.string.alpha(10); + const variable = (name: string, value: string) => ({ id: '', name, value, type: EnvironmentKvPairDataType.STRING }); + const keptName = faker.string.alpha(8); + const changedName = faker.string.alpha(8); + const removedName = faker.string.alpha(8); + const originalVariables = [variable(keptName, 'kept'), variable(changedName, 'old'), variable(removedName, 'removed')]; + const baseEnvironment = await environmentFlow.createBaseEnvironment(project, { + name: baseName, + type: ENVIRONMENT_TYPE, + kvPairData: originalVariables, + }); + await environmentFlow.createSubEnvironment(baseEnvironment, { + name: subName, + type: ENVIRONMENT_TYPE, + kvPairData: [variable('subVar', 'old')], + }); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + await environmentPage.selectEnvironment(baseName); + await environmentPage.setVariables([ + variable(keptName, 'kept'), + variable(changedName, 'new'), + variable(faker.string.alpha(8), 'added'), + ]); + await environmentPage.selectEnvironment(subName); + await environmentPage.setVariables([variable('subVar', 'new')]); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + const fileName = `${baseName.toLowerCase()}.yaml`; + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const cardsBefore = await gitCommitDialogPage.getEntityCardNames(); + const baseActions = await gitCommitDialogPage.getEntityCardActions(baseName); + await gitSyncFlow.discardEntity(fileName, baseName); + const cardsAfter = await gitCommitDialogPage.getEntityCardNames(); + await gitCommitDialogPage.closeCommitDialog(); + await environmentPage.selectEnvironment(baseName); + const baseJson = await environmentPage.getRawJson(); + await environmentPage.selectEnvironment(subName); + const subJson = await environmentPage.getRawJson(); + + const changedCookieKey = faker.string.alpha(8); + await cookieFlow.link(collection, [{ key: changedCookieKey, value: 'changed', domain: 'localhost', path: '/' }]); + const collectionFileName = `${collectionBaseName}.yaml`; + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, collectionFileName); + const jarActions = await gitCommitDialogPage.getEntityCardActions('Default Jar'); + await gitSyncFlow.discardEntity(collectionFileName, 'Default Jar'); + await expect.poll(async () => gitCommitDialogPage.getUnstagedChanges(), { timeout: DEFAULT_TIMEOUT }).not.toContain(collectionFileName); + await gitCommitDialogPage.closeCommitDialog(); + await cookiePage.open(); + const cookiesAfter = await cookiePage.getCookies(); + await cookiePage.close(); + + expect(cardsBefore.sort()).toEqual([baseName, subName].sort()); + expect(baseActions).toEqual(['Discard', 'Stage']); + expect(jarActions).toEqual(['Discard', 'Stage']); + expect(cookiesAfter.map(cookie => cookie.key)).not.toContain(changedCookieKey); + expect(cardsAfter).toEqual([subName]); + expect(JSON.parse(baseJson)).toEqual({ [keptName]: 'kept', [changedName]: 'old', [removedName]: 'removed' }); + expect(JSON.parse(subJson)).toEqual({ subVar: 'new' }); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/entity-cards-show-their-content.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/entity-cards-show-their-content.spec.ts new file mode 100644 index 0000000000..af0a0de83d --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/entity-cards-show-their-content.spec.ts @@ -0,0 +1,143 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ContentType } from '../../../enums/content-type'; +import { GitChangeList } from '../../../enums/git-change-list'; +import { GitDiffView } from '../../../enums/git-diff-view'; +import { HttpMethod } from '../../../enums/http-method'; +import { MockBodyType } from '../../../enums/mock-body-type'; +import { ProjectType } from '../../../enums/project-types'; +import { MCP_SERVER } from '../../../misc/constants'; +import { HTTP_SERVER, MOCK_BIN_SERVER, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_SERVER, WS_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { HttpRequest } from '../../../models/http-request'; +import { McpClient } from '../../../models/mcp-client'; +import { MockServer } from '../../../models/mock-server'; +import { Project } from '../../../models/project'; +import { SocketIORequest } from '../../../models/socket-io-request'; +import { WebSocketRequest } from '../../../models/websocket-request'; + +test('Verify the diff pane defaults to Text and every new entity card shows its badge, URL on one line and each populated section as its own tab', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, mcpClientFlow, mockServerFlow, preferencesFlow, socketIoRequestFlow, webSocketRequestFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const httpBaseName = faker.string.alpha(10).toLowerCase(); + const httpCollection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), httpBaseName); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const httpRequest = await httpRequestFlow.create( + httpCollection, + new HttpRequest({ + name: faker.string.alpha(10), + url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}/${faker.string.alphanumeric(120)}`, + method: HttpMethod.Post, + params: [{ name: 'page', value: '1' }], + headers: [{ name: 'X-Trace', value: faker.string.alphanumeric(8) }], + body: { mimeType: ContentType.JSON, text: '{"key":"value"}' }, + authentication: { + type: 'oauth2', + grantType: 'client_credentials', + accessTokenUrl: `${OAUTH2_SERVER}/token`, + clientId: OAUTH2_CLIENT_ID, + clientSecret: OAUTH2_CLIENT_SECRET, + }, + preRequestScript: "console.log('pre');", + afterResponseScript: "console.log('after');", + }), + ); + const realtimeBaseName = faker.string.alpha(10).toLowerCase(); + const realtimeCollection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), realtimeBaseName); + const webSocketRequest = await webSocketRequestFlow.create( + realtimeCollection, + new WebSocketRequest({ + name: faker.string.alpha(10), + url: `${WS_SERVER}/`, + params: [{ name: 'room', value: faker.string.alphanumeric(6) }], + headers: [{ name: 'X-Token', value: faker.string.alphanumeric(8) }], + }), + ); + const socketIoRequest = await socketIoRequestFlow.create( + realtimeCollection, + new SocketIORequest({ + name: faker.string.alpha(10), + url: HTTP_SERVER, + headers: [{ name: 'X-Token', value: faker.string.alphanumeric(8) }], + }), + ); + const mockServer = await mockServerFlow.create(project, new MockServer(faker.string.alphanumeric(10), MOCK_BIN_SERVER)); + const mockRoute = await mockServerFlow.createRoute(mockServer, { + path: `/${faker.string.alpha(8)}`, + method: HttpMethod.Post, + statusCode: 201, + statusText: 'Created', + bodyType: MockBodyType.Json, + body: '{"ok":true}', + }); + const mcpClient = await mcpClientFlow.create(project, new McpClient(faker.string.alphanumeric(10), MCP_SERVER)); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(4); + + await gitSyncPage.openCommitDialog(); + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, `${httpBaseName}.yaml`); + const defaultView = await gitCommitDialogPage.getDiffView(); + const hasTextOption = await gitCommitDialogPage.hasDiffViewOption(GitDiffView.Text); + const hasVisualOption = await gitCommitDialogPage.hasDiffViewOption(GitDiffView.Visual); + await gitCommitDialogPage.switchDiffView(GitDiffView.Visual); + const httpHeader = await gitCommitDialogPage.getEntityCardHeader(httpRequest.name); + const httpTabs = await gitCommitDialogPage.getEntityCardTabs(httpRequest.name); + const httpSections: Record = {}; + for (const tab of ['Params', 'Body', 'Auth', 'Headers', 'Scripts']) { + await gitCommitDialogPage.selectEntityCardTab(httpRequest.name, tab); + httpSections[tab] = await gitCommitDialogPage.getEntityCardText(httpRequest.name); + } + const httpFormatWarningAbsent = await gitCommitDialogPage.isVisualDiffFormatWarningAbsent(); + + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, `${realtimeBaseName}.yaml`); + const viewAfterSwitchingFile = await gitCommitDialogPage.getDiffView(); + const webSocketHeader = await gitCommitDialogPage.getEntityCardHeader(webSocketRequest.name); + const webSocketTabs = await gitCommitDialogPage.getEntityCardTabs(webSocketRequest.name); + await gitCommitDialogPage.selectEntityCardTab(webSocketRequest.name, 'Headers'); + const webSocketHeadersText = await gitCommitDialogPage.getEntityCardText(webSocketRequest.name); + const socketIoHeader = await gitCommitDialogPage.getEntityCardHeader(socketIoRequest.name); + const socketIoTabs = await gitCommitDialogPage.getEntityCardTabs(socketIoRequest.name); + const socketIoText = await gitCommitDialogPage.getEntityCardText(socketIoRequest.name); + + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, `${mockServer.name.toLowerCase()}.yaml`); + const routeText = await gitCommitDialogPage.getEntityCardText(mockRoute.path); + const routeTabs = await gitCommitDialogPage.getEntityCardTabs(mockRoute.path); + const serverText = await gitCommitDialogPage.getEntityCardText(MOCK_BIN_SERVER, 'Mock Server'); + + await gitCommitDialogPage.openChangeDiff(GitChangeList.Unstaged, `${mcpClient.name.toLowerCase()}.yaml`); + const mcpHeader = await gitCommitDialogPage.getEntityCardHeader('MCP Request'); + + expect(defaultView).toBe(GitDiffView.Text); + expect(hasTextOption).toBe(true); + expect(hasVisualOption).toBe(true); + expect(viewAfterSwitchingFile).toBe(GitDiffView.Visual); + expect(httpHeader).toMatchObject({ badge: 'POST', name: httpRequest.name, url: httpRequest.url, onOneLine: true }); + expect(httpTabs).toEqual(['Params 1', 'Body', 'Auth', 'Headers 2', 'Scripts']); + expect(httpSections.Params).toContain('page'); + expect(httpSections.Body).toContain('application/json'); + expect(httpSections.Body).toContain('{"key":"value"}'); + expect(httpSections.Auth).toContain('client_credentials'); + expect(httpSections.Auth).toContain(`${OAUTH2_SERVER}/token`); + expect(httpSections.Headers).toContain('X-Trace'); + expect(httpSections.Scripts).toContain("console.log('pre');"); + expect(httpSections.Scripts).toContain("console.log('after');"); + expect(httpFormatWarningAbsent).toBe(true); + expect(webSocketHeader).toEqual({ badge: 'WS', name: webSocketRequest.name, url: webSocketRequest.url, onOneLine: true }); + expect(webSocketTabs).toEqual(['Params 1', 'Headers 1']); + expect(webSocketHeadersText).toContain('X-Token'); + expect(socketIoHeader).toEqual({ badge: 'IO', name: socketIoRequest.name, url: socketIoRequest.url, onOneLine: true }); + expect(socketIoTabs).toEqual(['Headers 1']); + expect(socketIoText).toContain('X-Token'); + expect(routeText).toContain(`POST\n${mockRoute.path}\n201 Created`); + expect(routeTabs).toEqual(['Body']); + expect(routeText).toContain('application/json'); + expect(routeText).toContain('{"ok":true}'); + expect(serverText).toContain(MOCK_BIN_SERVER); + expect(mcpHeader).toEqual({ badge: 'MCP', name: 'MCP Request', url: `HTTP ${MCP_SERVER}`, onOneLine: true }); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/entity-type-collection-template.ts b/packages/insomnia-test/tests/git-sync/visual-diff/entity-type-collection-template.ts new file mode 100644 index 0000000000..d8ae17bead --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/entity-type-collection-template.ts @@ -0,0 +1,215 @@ +// The collection file of planEntityTypeFiles() exactly as Insomnia exports it (generated by replaying the minimal +// fixture through Git Sync's import -> export), so the Visual view treats it as a file Insomnia wrote and a +// stage/unstage round trip leaves nothing behind. __ID_n__ are, in order: workspace, folder, request, webSocket, +// socketIo, grpc, cookie jar, base environment; __NAME_x__ are GitEntityTypeNames; __WS_NAME__ is the workspace name. +const UNCHANGED = [ + "type: collection.insomnia.rest/5.0", + "schema_version: \"5.1\"", + "name: __WS_NAME__", + "meta:", + " id: __ID_0__", + " created: 1000", + " modified: 1000", + " description: \"\"", + "collection:", + " - name: __NAME_folder__", + " meta:", + " id: __ID_1__", + " created: 1000", + " modified: 1000", + " sortKey: 0", + " description: \"\"", + " children:", + " - url: https://example.com/a", + " name: __NAME_request__", + " meta:", + " id: __ID_2__", + " created: 1000", + " modified: 1000", + " isPrivate: false", + " description: \"\"", + " sortKey: 0", + " method: GET", + " settings:", + " renderRequestBody: true", + " encodeUrl: true", + " followRedirects: global", + " cookies:", + " send: true", + " store: true", + " rebuildPath: true", + " - url: ws://localhost:4040/", + " name: __NAME_webSocket__", + " meta:", + " id: __ID_3__", + " created: 1000", + " modified: 1000", + " description: \"\"", + " sortKey: 0", + " settings:", + " encodeUrl: true", + " cookies:", + " store: true", + " send: true", + " followRedirects: global", + " - url: http://localhost:4050/", + " name: __NAME_socketIo__", + " meta:", + " id: __ID_4__", + " created: 1000", + " modified: 1000", + " description: \"\"", + " sortKey: 0", + " settings:", + " encodeUrl: true", + " cookies:", + " store: true", + " send: true", + " - url: localhost:50051", + " name: __NAME_grpc__", + " meta:", + " id: __ID_5__", + " created: 1000", + " modified: 1000", + " isPrivate: false", + " description: \"\"", + " sortKey: 0", + " protoFileId: \"\"", + " reflectionApi:", + " enabled: false", + " url: \"\"", + " apiKey: \"\"", + " module: \"\"", + "cookieJar:", + " name: Default Jar", + " meta:", + " id: __ID_6__", + " created: 1000", + " modified: 1000", + "environments:", + " name: Base Environment", + " meta:", + " id: __ID_7__", + " created: 1000", + " modified: 1000", + " isPrivate: false" +].join('\n') + '\n'; +const CHANGED = [ + "type: collection.insomnia.rest/5.0", + "schema_version: \"5.1\"", + "name: __WS_NAME__", + "meta:", + " id: __ID_0__", + " created: 1000", + " modified: 1000", + " description: \"\"", + "collection:", + " - name: __NAME_folder__", + " meta:", + " id: __ID_1__", + " created: 1000", + " modified: 1000", + " sortKey: 0", + " description: \"\"", + " children:", + " - url: https://example.com/a", + " name: __NAME_request__", + " meta:", + " id: __ID_2__", + " created: 1000", + " modified: 1000", + " isPrivate: false", + " description: \"\"", + " sortKey: 0", + " method: GET", + " headers:", + " - name: X-Added", + " value: added", + " settings:", + " renderRequestBody: true", + " encodeUrl: true", + " followRedirects: global", + " cookies:", + " send: true", + " store: true", + " rebuildPath: true", + " headers:", + " - name: X-Added", + " value: added", + " - url: ws://localhost:4040/", + " name: __NAME_webSocket__", + " meta:", + " id: __ID_3__", + " created: 1000", + " modified: 1000", + " description: \"\"", + " sortKey: 0", + " settings:", + " encodeUrl: true", + " cookies:", + " store: true", + " send: true", + " followRedirects: global", + " headers:", + " - name: X-Added", + " value: added", + " - url: http://localhost:4050/", + " name: __NAME_socketIo__", + " meta:", + " id: __ID_4__", + " created: 1000", + " modified: 1000", + " description: \"\"", + " sortKey: 0", + " settings:", + " encodeUrl: true", + " cookies:", + " store: true", + " send: true", + " headers:", + " - name: X-Added", + " value: added", + " - url: localhost:50051", + " name: __NAME_grpc__", + " meta:", + " id: __ID_5__", + " created: 1000", + " modified: 1000", + " isPrivate: false", + " description: \"\"", + " sortKey: 0", + " metadata:", + " - name: X-Added", + " value: added", + " protoFileId: \"\"", + " reflectionApi:", + " enabled: false", + " url: \"\"", + " apiKey: \"\"", + " module: \"\"", + "cookieJar:", + " name: Default Jar", + " meta:", + " id: __ID_6__", + " created: 1000", + " modified: 1000", + " cookies:", + " - id: cookie_1", + " key: session", + " value: abc", + " domain: example.com", + " path: /", + " secure: false", + " httpOnly: false", + "environments:", + " name: Base Environment", + " meta:", + " id: __ID_7__", + " created: 1000", + " modified: 1000", + " isPrivate: false", + " data:", + " added: \"1\"" +].join('\n') + '\n'; + +export const COLLECTION_TEMPLATE = (changed: boolean) => (changed ? CHANGED : UNCHANGED); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/file-level-settings-appear.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/file-level-settings-appear.spec.ts new file mode 100644 index 0000000000..92c199f280 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/file-level-settings-appear.spec.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { planFileLevelSettings } from './helpers'; + +test('Verify file-level settings appear in the Visual view and can be staged, unstaged and discarded on their own', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const { names, build } = planFileLevelSettings(); + const specFile = `${faker.string.alpha(8).toLowerCase()}.yaml`; + const mockFile = `${faker.string.alpha(9).toLowerCase()}.yaml`; + const original = build(false); + const seedFiles: Record = { [specFile]: original.spec, [mockFile]: original.mock }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const changed = build(true); + fs.writeFileSync(path.join(cloneDir, specFile), changed.spec); + fs.writeFileSync(path.join(cloneDir, mockFile), changed.mock); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(2); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, specFile); + const specCards = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, mockFile); + const mockCards = await gitCommitDialogPage.getEntityCardNames(); + const settings = [ + { file: specFile, name: names.workspace, stagedName: names.previousWorkspace, label: 'API Spec' }, + { file: specFile, name: names.workspace, stagedName: names.previousWorkspace, label: 'Unit Tests' }, + { file: specFile, name: names.workspace, stagedName: names.previousWorkspace, label: 'CA Certificates' }, + { file: mockFile, name: names.mockServerUrl, stagedName: names.mockServerUrl, label: 'Mock Server' }, + { file: specFile, name: names.workspace, stagedName: names.workspace, label: 'Workspace' }, + ]; + for (const { file, name, stagedName, label } of settings) { + await gitSyncFlow.stageEntity(file, name, label); + await gitSyncFlow.unstageEntity(file, stagedName, label); + await gitSyncFlow.discardEntity(file, name, label); + } + await gitCommitDialogPage.closeCommitDialog(); + + expect(specCards).toEqual([names.workspace, names.workspace, names.workspace, names.workspace]); + expect(mockCards).toEqual([names.mockServerUrl]); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-only-touch-the-folder.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-only-touch-the-folder.spec.ts new file mode 100644 index 0000000000..fcdd03f542 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-only-touch-the-folder.spec.ts @@ -0,0 +1,62 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Folder } from '../../../models/folder'; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; + +test('Verify staging and discarding a folder does not touch the requests inside it, and discarding a new folder warns that its contents go too', async ({ user }) => { + const { folderFlow, gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + const createRequest = (parent: Collection | Folder) => + httpRequestFlow.create(parent, new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get })); + const folder = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + const request = await createRequest(folder); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const editedUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + await folderFlow.setScripts(folder, { preRequest: `console.log('${faker.string.alphanumeric(10)}');` }); + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(editedUrl); + const newFolder = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + const newNestedRequests = [await createRequest(newFolder), await createRequest(newFolder)]; + const emptyFolder = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(fileName, folder.name); + const unstagedCardsAfterStage = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedCards = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.unstageEntity(fileName, folder.name); + await gitSyncFlow.discardEntity(fileName, folder.name); + const unstagedCardsAfterDiscard = await gitCommitDialogPage.getEntityCardNames(); + await gitCommitDialogPage.startEntityDiscard(newFolder.name); + const contentsConfirmLabel = await gitCommitDialogPage.getEntityDiscardConfirmLabel(newFolder.name); + await gitCommitDialogPage.confirmEntityDiscard(newFolder.name); + await gitCommitDialogPage.startEntityDiscard(emptyFolder.name); + const emptyConfirmLabel = await gitCommitDialogPage.getEntityDiscardConfirmLabel(emptyFolder.name); + await gitCommitDialogPage.confirmEntityDiscard(emptyFolder.name); + const unstagedCardsAtEnd = await gitCommitDialogPage.getEntityCardNames(); + await gitCommitDialogPage.closeCommitDialog(); + const requestAfterDiscard = await httpRequestFlow.get(request); + + const newFolderCards = [newFolder, ...newNestedRequests, emptyFolder].map(entity => entity.name); + expect(unstagedCardsAfterStage.sort()).toEqual([request.name, ...newFolderCards].sort()); + expect(stagedCards).toEqual([folder.name]); + expect(unstagedCardsAfterDiscard.sort()).toEqual([request.name, ...newFolderCards].sort()); + expect(contentsConfirmLabel).toBe('Confirm (discards its contents too)'); + expect(emptyConfirmLabel).toBe('Confirm'); + expect(unstagedCardsAtEnd).toEqual([request.name]); + expect(requestAfterDiscard?.url).toBe(editedUrl); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/format-warning-and-unknown-fields-warning-do-not-stack.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/format-warning-and-unknown-fields-warning-do-not-stack.spec.ts new file mode 100644 index 0000000000..80b4f466af --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/format-warning-and-unknown-fields-warning-do-not-stack.spec.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { GitDiffView } from '../../../enums/git-diff-view'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from '../undiscardable-change/helpers'; +import { buildCollectionFileWithUnknownField } from '../unknown-fields/helpers'; + +test('Verify a file whose committed version is not in the format Insomnia writes shows a warning above its cards, and a file with unrecognized fields shows only the unrecognized-fields warning', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + const reorderedFile = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const reordered = buildReorderedCollectionFile(faker.string.alpha(10)); + const unknownFieldsFile = `${faker.string.alpha(11).toLowerCase()}.yaml`; + const unknownFieldsRequestName = faker.string.alpha(10); + const unknownFields = buildCollectionFileWithUnknownField(faker.string.alpha(10), { requestName: unknownFieldsRequestName }); + await pushServerFile({ path: reorderedFile, content: reordered.content }); + await pushServerFile({ path: unknownFieldsFile, content: unknownFields.content }); + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const editedUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + for (const requestName of [reordered.requestName, unknownFieldsRequestName]) { + await httpRequestFlow.get(new HttpRequest({ name: requestName, url: '', method: HttpMethod.Get })); + await httpRequestPage.setUrl(editedUrl); + } + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(2); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, reorderedFile); + const reorderedCards = await gitCommitDialogPage.getEntityCardNames(); + const reorderedWarning = await gitCommitDialogPage.getVisualDiffFormatWarning(); + const reorderedStageEnabled = await gitCommitDialogPage.isEntityStageEnabled(reordered.requestName); + const reorderedHasUnknownFieldsBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + await gitCommitDialogPage.openTextViewFromWarning(); + const viewAfterWarningLink = await gitCommitDialogPage.getDiffView(); + const workingFile = fs.readFileSync(path.join(cloneDir, reorderedFile), 'utf8'); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, unknownFieldsFile); + const unknownFieldsCards = await gitCommitDialogPage.getEntityCardNames(); + const unknownFieldsBanner = await gitCommitDialogPage.isUnknownFieldsDiffWarningVisible(); + const unknownFieldsFormatWarningAbsent = await gitCommitDialogPage.isVisualDiffFormatWarningAbsent(); + + expect(reorderedCards).toContain(reordered.requestName); + expect(reorderedWarning).toContain("This file isn't in the format Insomnia writes"); + expect(reorderedWarning).toContain('staging or discarding a card rewrites the whole file'); + expect(reorderedStageEnabled).toBe(true); + expect(reorderedHasUnknownFieldsBanner).toBe(false); + expect(viewAfterWarningLink).toBe(GitDiffView.Text); + expect(workingFile).toContain(editedUrl); + expect(unknownFieldsCards).toContain(unknownFieldsRequestName); + expect(unknownFieldsBanner).toBe(true); + expect(unknownFieldsFormatWarningAbsent).toBe(true); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/helpers.ts b/packages/insomnia-test/tests/git-sync/visual-diff/helpers.ts new file mode 100644 index 0000000000..2594166042 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/helpers.ts @@ -0,0 +1,252 @@ + +import { faker } from '@faker-js/faker'; + +import type { GitEntityTypeFiles, GitEntityTypeNames } from '../../../models/git-entity-type-files'; +import type { GitFileLevelSettings } from '../../../models/git-file-level-settings'; +import type { GitLongValues } from '../../../models/git-long-values'; +import type { GitMovedRequest } from '../../../models/git-moved-request'; +import { COLLECTION_TEMPLATE } from './entity-type-collection-template'; + +const FILE_TIMES = { created: 1000, modified: 1000 }; + +export function planEntityTypeFiles(): GitEntityTypeFiles { + const names: GitEntityTypeNames = { + folder: faker.string.alpha(10), + request: faker.string.alpha(10), + webSocket: faker.string.alpha(10), + socketIo: faker.string.alpha(10), + grpc: faker.string.alpha(10), + mockRoute: `/${faker.string.alpha(10)}`, + mcp: faker.string.alpha(10), + }; + const ids = { + workspace: faker.string.alphanumeric({ length: 16, casing: 'lower' }), + folder: `fld_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + request: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + webSocket: `ws-req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + socketIo: `socketio-req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + grpc: `greq_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + environment: `env_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + cookieJar: `jar_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mockWorkspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mockServer: `mock-server_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mockRoute: `mock-route_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mcpWorkspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mcp: `mcp-req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + }; + const workspaceNames = { collection: faker.string.alpha(10), mock: faker.string.alpha(10), mcp: faker.string.alpha(10) }; + const header = (changed: boolean) => (changed ? [{ name: 'X-Added', value: 'added' }] : undefined); + const collectionIds = [`wrk_${ids.workspace}`, ids.folder, ids.request, ids.webSocket, ids.socketIo, ids.grpc, ids.cookieJar, ids.environment]; + const renderCollectionTemplate = (changed: boolean) => { + let content = COLLECTION_TEMPLATE(changed).replaceAll('__WS_NAME__', workspaceNames.collection); + for (const [key, value] of Object.entries(names)) { + content = content.replaceAll(`__NAME_${key}__`, value); + } + collectionIds.forEach((id, index) => { + content = content.replaceAll(`__ID_${index}__`, id); + }); + return content; + }; + const build = (changed: boolean) => ({ + collection: renderCollectionTemplate(changed), + mock: JSON.stringify( + { + type: 'mock.insomnia.rest/5.0', + schema_version: '5.1', + name: workspaceNames.mock, + meta: { id: ids.mockWorkspace, ...FILE_TIMES }, + server: { + meta: { id: ids.mockServer, ...FILE_TIMES }, + url: 'https://mock.example.com', + useInsomniaCloud: false, + }, + routes: [ + { + name: names.mockRoute, + method: 'GET', + statusCode: 200, + mimeType: 'application/json', + meta: { id: ids.mockRoute, ...FILE_TIMES }, + headers: header(changed), + }, + ], + }, + null, + 2, + ), + mcp: JSON.stringify( + { + type: 'mcpClient.insomnia/5.0', + name: workspaceNames.mcp, + meta: { id: ids.mcpWorkspace, ...FILE_TIMES }, + mcpRequest: { + name: names.mcp, + url: 'npx server', + transportType: 'stdio', + meta: { id: ids.mcp, ...FILE_TIMES }, + headers: header(changed), + }, + }, + null, + 2, + ), + }); + return { names, build }; +} + +export function planFileLevelSettings(): GitFileLevelSettings { + const names = { workspace: faker.string.alpha(10), previousWorkspace: faker.string.alpha(11), mockServerUrl: `https://${faker.string.alpha(8)}.example.com`, mockWorkspace: faker.string.alpha(10) }; + const ids = { + specWorkspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + spec: `spc_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + suite: `uts_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + certificate: `crt_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mockWorkspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + mockServer: `mock-server_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + }; + const build = (changed: boolean) => ({ + spec: JSON.stringify( + { + type: 'spec.insomnia.rest/5.0', + schema_version: '5.1', + name: changed ? names.workspace : names.previousWorkspace, + meta: { id: ids.specWorkspace, ...FILE_TIMES }, + spec: { + meta: { id: ids.spec, ...FILE_TIMES }, + contents: { openapi: '3.0.0', info: { title: changed ? 'New title' : 'Old title', version: '1.0.0' }, paths: {} }, + }, + testSuites: changed ? [{ name: 'Suite', meta: { id: ids.suite, ...FILE_TIMES }, tests: [] }] : undefined, + certificates: changed ? [{ path: '/tmp/ca.pem', meta: { id: ids.certificate, ...FILE_TIMES } }] : undefined, + }, + null, + 2, + ), + mock: JSON.stringify( + { + type: 'mock.insomnia.rest/5.0', + schema_version: '5.1', + name: names.mockWorkspace, + meta: { id: ids.mockWorkspace, ...FILE_TIMES }, + server: { + meta: { id: ids.mockServer, ...FILE_TIMES }, + url: changed ? names.mockServerUrl : 'https://old.example.com', + useInsomniaCloud: false, + }, + }, + null, + 2, + ), + }); + + return { names, build }; +} + +export function planMovedRequest(): GitMovedRequest { + const ids = { workspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, folder: `fld_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, request: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, environment: `env_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}` }; + const names = { workspace: faker.string.alpha(10), folder: faker.string.alpha(10), request: faker.string.alpha(10) }; + const build = (moved: boolean) => { + const request = { + name: names.request, + url: moved ? 'https://example.com/moved-and-edited' : 'https://example.com/original', + method: 'GET', + meta: { id: ids.request, ...FILE_TIMES, isPrivate: false }, + settings: { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }, + }; + const folder = { name: names.folder, meta: { id: ids.folder, ...FILE_TIMES }, children: moved ? [request] : undefined }; + return JSON.stringify( + { + type: 'collection.insomnia.rest/5.0', + schema_version: '5.1', + name: names.workspace, + meta: { id: ids.workspace, ...FILE_TIMES }, + collection: moved ? [folder] : [folder, request], + environments: { name: 'Base Environment', meta: { id: ids.environment, ...FILE_TIMES, isPrivate: false } }, + }, + null, + 2, + ); + }; + + return { requestId: ids.request, requestName: names.request, folderName: names.folder, build }; +} + +export function planLongValues(): GitLongValues { + const ids = { workspace: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, longRequest: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, shortRequest: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, spec: `spc_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}` }; + const names = { workspace: faker.string.alpha(10), longRequest: faker.string.alpha(10), shortRequest: faker.string.alpha(10) }; + const totalLines = 40; + const longText = (changed: boolean) => + Array.from({ length: totalLines }, (_, index) => (index === 20 && changed ? 'changed line' : `line ${index}`)).join('\n'); + const specPaths = (changed: boolean) => + Object.fromEntries( + Array.from({ length: totalLines }, (_, index) => [`/path-${index}`, { get: { summary: index === 20 && changed ? 'changed summary' : `summary ${index}` } }]), + ); + const build = (changed: boolean) => + JSON.stringify( + { + type: 'spec.insomnia.rest/5.0', + schema_version: '5.1', + name: names.workspace, + meta: { id: ids.workspace, ...FILE_TIMES }, + collection: [ + { + name: names.longRequest, + url: 'https://example.com/long', + method: 'POST', + meta: { id: ids.longRequest, ...FILE_TIMES }, + body: { mimeType: 'text/plain', text: longText(changed) }, + scripts: { preRequest: longText(changed) }, + }, + { + name: names.shortRequest, + url: 'https://example.com/short', + method: 'POST', + meta: { id: ids.shortRequest, ...FILE_TIMES }, + body: { mimeType: 'text/plain', text: changed ? 'short new' : 'short old' }, + }, + ], + spec: { meta: { id: ids.spec, ...FILE_TIMES }, contents: { openapi: '3.0.0', info: { title: 'T' }, paths: specPaths(changed) } }, + }, + null, + 2, + ); + + return { longRequest: names.longRequest, shortRequest: names.shortRequest, workspace: names.workspace, totalLines, build }; +} + + +const SEED_REQUEST_SETTINGS = { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }; + +export function buildSeedRequest(id: string, name: string, url: string, extra: Record = {}) { + return { name, url, method: 'GET', meta: { id, created: 1000, modified: 1000, isPrivate: false }, settings: SEED_REQUEST_SETTINGS, ...extra }; +} + +export function buildSeedFolder(id: string, name: string, children?: unknown[]) { + return { name, meta: { id, created: 1000, modified: 1000 }, children }; +} + +export function buildSeedCollectionFile(workspace: { id: string; name: string; environmentId: string }, collection: unknown[]) { + return JSON.stringify( + { + type: 'collection.insomnia.rest/5.0', + schema_version: '5.1', + name: workspace.name, + meta: { id: workspace.id, created: 1000, modified: 1000 }, + collection, + environments: { name: 'Base Environment', meta: { id: workspace.environmentId, created: 1000, modified: 1000, isPrivate: false } }, + }, + null, + 2, + ); +} + +export function planSeedWorkspace() { + return { + id: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + name: faker.string.alpha(10), + environmentId: `env_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, + }; +} + +export function planSeedId(prefix: 'fld' | 'req') { + return `${prefix}_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`; +} diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/long-values-show-compact-diff.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/long-values-show-compact-diff.spec.ts new file mode 100644 index 0000000000..d7e5726893 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/long-values-show-compact-diff.spec.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { planLongValues } from './helpers'; + +test('Verify long changed values render as a compact diff with unchanged lines folded and short values stay inline', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const { longRequest, shortRequest, workspace, totalLines, build } = planLongValues(); + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const seedFiles: Record = { [fileName]: build(false) }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.writeFileSync(path.join(cloneDir, fileName), build(true)); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const body = await gitCommitDialogPage.getEntityCardCompactDiff(longRequest, 'Body'); + const script = await gitCommitDialogPage.getEntityCardCompactDiff(longRequest, 'Scripts'); + const spec = await gitCommitDialogPage.getEntityCardCompactDiff(workspace, undefined, 'API Spec'); + const shortValueIsCompact = await gitCommitDialogPage.hasEntityCardCompactDiff(shortRequest); + const shortValueText = await gitCommitDialogPage.getEntityCardText(shortRequest); + + for (const diff of [body, script, spec]) { + expect(diff.editors).toBe(1); + expect(diff.foldedRegions).toBeGreaterThan(0); + expect(diff.drawnLines).toBeLessThan(totalLines); + } + expect(shortValueIsCompact).toBe(false); + expect(shortValueText).toContain('short old'); + expect(shortValueText).toContain('short new'); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/modified-request-and-folder-cards-show-changes.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/modified-request-and-folder-cards-show-changes.spec.ts new file mode 100644 index 0000000000..1b2c7c6f51 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/modified-request-and-folder-cards-show-changes.spec.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { ContentType } from '../../../enums/content-type'; +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { Folder } from '../../../models/folder'; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; + +test('Verify a modified request card shows its old and new header values and each edited section, a folder card shows its script, and changed fields no tab covers appear in a Settings tab', async ({ user }) => { + const { folderFlow, gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), baseName); + const oldUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + const newUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(12)}`; + const request = await httpRequestFlow.create(collection, new HttpRequest({ name: faker.string.alpha(10), url: oldUrl, method: HttpMethod.Get })); + const folder = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + + await httpRequestFlow.get(request); + await httpRequestPage.setBody({ mimeType: ContentType.JSON, text: '{"edited":true}' }); + await httpRequestPage.setMethod(HttpMethod.Post); + await httpRequestPage.setUrl(newUrl); + await httpRequestPage.setParams([{ name: 'page', value: '2' }]); + await httpRequestPage.scripts.setScripts({ preRequest: "console.log('edited');" }); + await folderFlow.setScripts(folder, { preRequest: "console.log('folder');" }); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + // Fields without a dedicated tab: a request send setting, and a confidential variable on the base environment + const confidentialKey = faker.string.alpha(10); + const original = fs.readFileSync(path.join(cloneDir, `${baseName}.yaml`), 'utf8'); + const withSettings = original + .replace('followRedirects: global', 'followRedirects: "off"') + .replace(/^( *)name: Base Environment$/m, `$1name: Base Environment\n$1data:\n$1 ${confidentialKey}: secret\n$1confidentialKeys:\n$1 - ${confidentialKey}`); + expect(withSettings).not.toBe(original); + fs.writeFileSync(path.join(cloneDir, `${baseName}.yaml`), withSettings); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${baseName}.yaml`); + const requestText = await gitCommitDialogPage.getEntityCardText(request.name); + const requestTabs = await gitCommitDialogPage.getEntityCardTabs(request.name); + const folderText = await gitCommitDialogPage.getEntityCardText(folder.name); + await gitCommitDialogPage.selectEntityCardTab(request.name, 'Settings'); + const requestSettingsText = await gitCommitDialogPage.getEntityCardText(request.name); + const environmentTabs = await gitCommitDialogPage.getEntityCardTabs('Base Environment'); + await gitCommitDialogPage.selectEntityCardTab('Base Environment', 'Settings'); + const environmentSettingsText = await gitCommitDialogPage.getEntityCardText('Base Environment'); + const folderTabs = await gitCommitDialogPage.getEntityCardTabs(folder.name); + + expect(requestText).toContain(`POST\n${request.name}\n${newUrl}`); + expect(requestText).toContain(`GET\n${oldUrl}`); + expect(requestTabs).toEqual(['Params 1', 'Body', 'Headers 1', 'Scripts', 'Settings 1']); + expect(folderTabs).toEqual(['Scripts']); + expect(folderText).toContain("console.log('folder');"); + expect(requestSettingsText).toContain('followRedirects'); + expect(requestSettingsText).toContain('global'); + expect(requestSettingsText).toContain('off'); + expect(environmentTabs).toContain('Settings 1'); + expect(environmentSettingsText).toContain('Confidential Keys'); + expect(environmentSettingsText).toContain(confidentialKey); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/moved-and-deleted-entities-restore-correctly.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/moved-and-deleted-entities-restore-correctly.spec.ts new file mode 100644 index 0000000000..9e0bd999e7 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/moved-and-deleted-entities-restore-correctly.spec.ts @@ -0,0 +1,134 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildSeedCollectionFile, buildSeedFolder, buildSeedRequest, planMovedRequest, planSeedId, planSeedWorkspace } from './helpers'; + +test('Verify moved requests are never duplicated by stage, unstage or discard, discarding a new folder a request moved into brings the request back to its place, and a deleted folder is restored without its contents', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const occurrences = (text: string, id: string) => text.split(id).length - 1; + const readFile = (cloneDir: string, fileName: string) => fs.readFileSync(path.join(cloneDir, fileName), 'utf8'); + const readIndex = (cloneDir: string, fileName: string) => execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const newFileName = () => `${faker.string.alpha(10).toLowerCase()}.yaml`; + + // A request that moved into an existing folder + const moved = planMovedRequest(); + const movedFile = newFileName(); + // A request that moved into a folder created in the working tree + const intoNewFolder = { workspace: planSeedWorkspace(), folder: { id: planSeedId('fld'), name: faker.string.alpha(10) }, request: { id: planSeedId('req'), name: faker.string.alpha(10) } }; + const intoNewFolderFile = newFileName(); + const intoNewFolderRequest = () => buildSeedRequest(intoNewFolder.request.id, intoNewFolder.request.name, 'https://example.com/moved'); + // A folder with two requests that was deleted in the working tree + const deleted = { + workspace: planSeedWorkspace(), + folder: { id: planSeedId('fld'), name: faker.string.alpha(10) }, + children: [ + { id: planSeedId('req'), name: faker.string.alpha(10) }, + { id: planSeedId('req'), name: faker.string.alpha(10) }, + ], + }; + const deletedFile = newFileName(); + const seedFiles: Record = { + [movedFile]: moved.build(false), + [intoNewFolderFile]: buildSeedCollectionFile(intoNewFolder.workspace, [intoNewFolderRequest()]), + [deletedFile]: buildSeedCollectionFile(deleted.workspace, [ + buildSeedFolder( + deleted.folder.id, + deleted.folder.name, + deleted.children.map((child, index) => buildSeedRequest(child.id, child.name, `https://example.com/${index}`)), + ), + ]), + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.writeFileSync(path.join(cloneDir, movedFile), moved.build(true)); + fs.writeFileSync( + path.join(cloneDir, intoNewFolderFile), + buildSeedCollectionFile(intoNewFolder.workspace, [buildSeedFolder(intoNewFolder.folder.id, intoNewFolder.folder.name, [intoNewFolderRequest()])]), + ); + fs.writeFileSync(path.join(cloneDir, deletedFile), buildSeedCollectionFile(deleted.workspace, [])); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(3); + + // Moved into an existing folder + await gitSyncFlow.stageEntity(movedFile, moved.requestName); + const movedCopiesAfterStage = occurrences(readIndex(cloneDir, movedFile), moved.requestId); + await gitSyncFlow.unstageEntity(movedFile, moved.requestName); + const movedCopiesAfterUnstage = occurrences(readIndex(cloneDir, movedFile), moved.requestId); + await gitSyncFlow.discardEntity(movedFile, moved.requestName); + const movedCopiesAfterDiscard = occurrences(readFile(cloneDir, movedFile), moved.requestId); + + // Moved into a new folder + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, intoNewFolderFile); + const intoNewFolderCardsBefore = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.stageEntity(intoNewFolderFile, intoNewFolder.folder.name); + await gitSyncFlow.unstageEntity(intoNewFolderFile, intoNewFolder.folder.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, intoNewFolderFile); + await gitCommitDialogPage.startEntityDiscard(intoNewFolder.folder.name); + const intoNewFolderConfirmLabel = await gitCommitDialogPage.getEntityDiscardConfirmLabel(intoNewFolder.folder.name); + await gitCommitDialogPage.confirmEntityDiscard(intoNewFolder.folder.name); + const intoNewFolderCardsAfterFolderDiscard = await gitCommitDialogPage.getEntityCardNames(); + const intoNewFolderRequestCardText = await gitCommitDialogPage.getEntityCardText(intoNewFolder.request.name); + await gitSyncFlow.discardEntity(intoNewFolderFile, intoNewFolder.request.name); + const intoNewFolderRestored = readFile(cloneDir, intoNewFolderFile); + + // Deleted folder with contents + const countDeleted = (text: string) => ({ + folders: occurrences(text, deleted.folder.id), + requests: deleted.children.map(child => occurrences(text, child.id)), + }); + await gitSyncFlow.stageEntity(deletedFile, deleted.folder.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, deletedFile); + const deletedStagedAfterStage = await gitCommitDialogPage.getEntityCardNames(); + const deletedStagedStatuses = [ + await gitCommitDialogPage.getEntityCardText(deleted.folder.name), + await gitCommitDialogPage.getEntityCardText(deleted.children[0].name), + await gitCommitDialogPage.getEntityCardText(deleted.children[1].name), + ].map(text => text.includes('Removed')); + await gitSyncFlow.unstageEntity(deletedFile, deleted.folder.name); + const deletedStagedAfterUnstageFolder = await gitCommitDialogPage.getEntityCardNames(); + const deletedIndexAfterUnstageFolder = countDeleted(readIndex(cloneDir, deletedFile)); + await gitSyncFlow.discardEntity(deletedFile, deleted.folder.name); + const deletedFileAfterDiscardFolder = countDeleted(readFile(cloneDir, deletedFile)); + for (const child of deleted.children) { + await gitSyncFlow.unstageEntity(deletedFile, child.name); + } + const deletedIndexAfterUnstageChildren = countDeleted(readIndex(cloneDir, deletedFile)); + for (const child of deleted.children) { + await gitSyncFlow.discardEntity(deletedFile, child.name); + } + const deletedRestored = readFile(cloneDir, deletedFile); + + expect([movedCopiesAfterStage, movedCopiesAfterUnstage, movedCopiesAfterDiscard]).toEqual([1, 1, 1]); + expect(intoNewFolderCardsBefore).toEqual([intoNewFolder.folder.name]); + expect(intoNewFolderConfirmLabel).toBe('Confirm (discards its contents too)'); + expect(intoNewFolderCardsAfterFolderDiscard).toEqual([intoNewFolder.request.name]); + expect(intoNewFolderRequestCardText).toContain('Removed'); + expect(occurrences(intoNewFolderRestored, intoNewFolder.request.id)).toBe(1); + expect(occurrences(intoNewFolderRestored, intoNewFolder.folder.id)).toBe(0); + expect(deletedStagedAfterStage.sort()).toEqual([deleted.folder.name, ...deleted.children.map(child => child.name)].sort()); + expect(deletedStagedStatuses).toEqual([true, true, true]); + expect(deletedStagedAfterUnstageFolder.sort()).toEqual(deleted.children.map(child => child.name).sort()); + expect(deletedIndexAfterUnstageFolder).toEqual({ folders: 1, requests: [0, 0] }); + expect(deletedFileAfterDiscardFolder).toEqual({ folders: 1, requests: [0, 0] }); + expect(deletedIndexAfterUnstageChildren).toEqual({ folders: 1, requests: [1, 1] }); + expect(countDeleted(deletedRestored)).toEqual({ folders: 1, requests: [1, 1] }); + expect(deletedRestored.indexOf(deleted.folder.id)).toBeLessThan(deletedRestored.indexOf(deleted.children[0].id)); + expect(deletedRestored).toContain('children:'); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/new-and-deleted-files-offer-limited-actions.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/new-and-deleted-files-offer-limited-actions.spec.ts new file mode 100644 index 0000000000..d16ad5c3d0 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/new-and-deleted-files-offer-limited-actions.spec.ts @@ -0,0 +1,64 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; + +const BASE_ENVIRONMENT = 'Base Environment'; +const DEFAULT_JAR = 'Default Jar'; + +test('Verify an added or deleted file has no Workspace actions, a deleted file offers no per-entity Discard, and the Base Environment and Default Jar of a new file cannot be discarded', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const createRequest = (parent: Collection) => + httpRequestFlow.create(parent, new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get })); + const deletedBaseName = faker.string.alpha(10).toLowerCase(); + const deletedCollection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), deletedBaseName); + const deletedRequest = await createRequest(deletedCollection); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const addedBaseName = faker.string.alpha(10).toLowerCase(); + const addedCollection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), addedBaseName); + const addedRequests = [await createRequest(addedCollection), await createRequest(addedCollection)]; + await workspaceFlow.delete(deletedCollection); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(2); + + const deletedFileName = `${deletedBaseName}.yaml`; + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, deletedFileName); + const deletedWorkspaceActions = await gitCommitDialogPage.getEntityCardActions(deletedCollection.name); + const deletedRequestActions = await gitCommitDialogPage.getEntityCardActions(deletedRequest.name); + const addedFileName = `${addedBaseName}.yaml`; + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, addedFileName); + const addedWorkspaceActions = await gitCommitDialogPage.getEntityCardActions(addedCollection.name); + const addedRequestActions = await gitCommitDialogPage.getEntityCardActions(addedRequests[0].name); + for (const request of addedRequests) { + await gitSyncFlow.discardEntity(addedFileName, request.name); + } + const remainingCards = await gitCommitDialogPage.getEntityCardNames(); + const environmentActions = await gitCommitDialogPage.getEntityCardActions(BASE_ENVIRONMENT); + const jarActions = await gitCommitDialogPage.getEntityCardActions(DEFAULT_JAR); + const unstaged = await gitCommitDialogPage.getUnstagedChanges(); + + expect(deletedWorkspaceActions).toEqual([]); + expect(deletedRequestActions).toEqual(['Stage']); + expect(addedWorkspaceActions).toEqual([]); + expect(addedRequestActions).toEqual(['Discard', 'Stage']); + expect(remainingCards).toContain(BASE_ENVIRONMENT); + expect(remainingCards).toContain(DEFAULT_JAR); + for (const request of addedRequests) { + expect(remainingCards).not.toContain(request.name); + } + expect(environmentActions).toEqual(['Stage']); + expect(jarActions).toEqual(['Stage']); + expect(unstaged).toContain(addedFileName); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/same-named-headers-are-numbered.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/same-named-headers-are-numbered.spec.ts new file mode 100644 index 0000000000..0b6f80ad73 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/same-named-headers-are-numbered.spec.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildSeedCollectionFile, buildSeedRequest } from './helpers'; + +test('Verify same-named headers on a request card are told apart as (1) and (2)', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const workspace = { id: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10), environmentId: `env_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}` }; + const request = { id: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }; + const headers = (defaultAgent: string, customAgent: string) => [ + { name: 'User-Agent', value: defaultAgent, disabled: true }, + { name: 'User-Agent', value: customAgent }, + ]; + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const seedFiles: Record = { + [fileName]: buildSeedCollectionFile(workspace, [ + buildSeedRequest(request.id, request.name, 'https://example.com', { headers: headers('insomnia/12.1.0', '123') }), + ]), + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.writeFileSync(path.join(cloneDir, fileName), buildSeedCollectionFile(workspace, [ + buildSeedRequest(request.id, request.name, 'https://example.com', { headers: headers('insomnia/12.2.0-beta', '4567') }), + ])); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const tabs = await gitCommitDialogPage.getEntityCardTabs(request.name); + const cardText = await gitCommitDialogPage.getEntityCardText(request.name); + + expect(tabs).toEqual(['Headers 2']); + expect(cardText).toContain('User-Agent (1)'); + expect(cardText).toContain('User-Agent (2)'); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/stage-in-file-with-non-canonical-head.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/stage-in-file-with-non-canonical-head.spec.ts new file mode 100644 index 0000000000..4467cb816f --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/stage-in-file-with-non-canonical-head.spec.ts @@ -0,0 +1,44 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; +import { buildReorderedCollectionFile } from '../undiscardable-change/helpers'; + +test('Verify staging one request in a file whose HEAD is not canonical only stages that request', async ({ user }) => { + test.fail(true, "INS-4078"); + + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const { content, requestName } = buildReorderedCollectionFile(faker.string.alpha(10)); + const seedFiles: Record = { [fileName]: content }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const editedUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + await httpRequestFlow.get(new HttpRequest({ name: requestName, url: '', method: HttpMethod.Get })); + await httpRequestPage.setUrl(editedUrl); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(fileName, requestName); + const stagedDiff = execSync(`git diff --cached -U0 -- ${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + const stagedLines = stagedDiff.split('\n').filter(line => /^[+-]/.test(line) && !/^(\+\+\+|---)/.test(line)); + + expect(stagedLines.some(line => line.includes(editedUrl))).toBe(true); + expect(stagedLines.length).toBeLessThanOrEqual(4); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/stage-unstage-discard-requests-flow.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/stage-unstage-discard-requests-flow.spec.ts new file mode 100644 index 0000000000..9fc59d82b6 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/stage-unstage-discard-requests-flow.spec.ts @@ -0,0 +1,124 @@ +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, test } from "../../../misc/git-fixtures"; +import { Collection } from '../../../models/collection'; +import { HttpRequest } from '../../../models/http-request'; +import { Project } from '../../../models/project'; + +test('Verify staging, unstaging and discarding individual requests from the Visual (Beta) view keeps the Staged and Unstaged lists consistent, ignores clicks during a pending action and never lets two actions overwrite each other', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage, gitCommitDialogPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.createCollection(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + const requests: HttpRequest[] = []; + for (let i = 0; i < 6; i++) { + requests.push( + await httpRequestFlow.create( + collection, + new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get }), + ), + ); + } + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const [ignored, discarded, burst1, burst2, burst3, discardedLater] = [requests[0], requests[1], requests[2], requests[3], requests[4], requests[5]]; + // `ignored` keeps its edit through the lapsed discard and becomes the first staged request + const editedUrls = new Map(); + for (const request of requests) { + const editedUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + editedUrls.set(request.name, editedUrl); + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(editedUrl); + } + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + + const ignoredDiscard = await test.step('Discard needs a confirmation click and a lapsed prompt changes nothing', async () => { + await gitCommitDialogPage.startEntityDiscard(ignored.name); + await gitCommitDialogPage.waitForEntityDiscardPromptToLapse(ignored.name); + return { + cardVisible: await gitCommitDialogPage.isEntityCardVisible(ignored.name), + actions: await gitCommitDialogPage.getEntityCardActions(ignored.name), + }; + }); + + const confirmedDiscard = await test.step('Confirming the discard of one request keeps the other edits', async () => { + await gitCommitDialogPage.startEntityDiscard(discarded.name); + const confirmLabel = await gitCommitDialogPage.getEntityDiscardConfirmLabel(discarded.name); + await gitCommitDialogPage.confirmEntityDiscard(discarded.name); + const remainingCards = await gitCommitDialogPage.getEntityCardNames(); + await gitCommitDialogPage.closeCommitDialog(); + return { + confirmLabel, + remainingCards, + discardedUrl: (await httpRequestFlow.get(discarded))?.url, + keptUrl: (await httpRequestFlow.get(ignored))?.url, + }; + }); + + const afterFirstStage = await test.step('Staging one request leaves the others unstaged and each side offers the right actions', async () => { + await gitSyncFlow.stageEntity(fileName, ignored.name); + const unstagedCards = await gitCommitDialogPage.getEntityCardNames(); + const unstagedActions = await gitCommitDialogPage.getEntityCardActions(burst1.name); + const files = { staged: await gitCommitDialogPage.getStagedChanges(), unstaged: await gitCommitDialogPage.getUnstagedChanges() }; + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + return { + unstagedCards, + unstagedActions, + files, + stagedCards: await gitCommitDialogPage.getEntityCardNames(), + stagedActions: await gitCommitDialogPage.getEntityCardActions(ignored.name), + }; + }); + + const afterBurst = await test.step('Clicks made while an action is pending are ignored', async () => { + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitCommitDialogPage.stageEntitiesInQuickSuccession([burst1.name, burst2.name, burst3.name]); + const unstagedCards = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + return { unstagedCards, stagedCards: await gitCommitDialogPage.getEntityCardNames() }; + }); + + const afterUnstageAndDiscard = await test.step('Unstaging and discarding keep both lists consistent', async () => { + await gitSyncFlow.unstageEntity(fileName, ignored.name); + const stagedCards = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.discardEntity(fileName, discardedLater.name); + return { stagedCards, unstagedCards: await gitCommitDialogPage.getEntityCardNames() }; + }); + + await test.step('Stage this file clicked while an entity action is pending overwrites neither action', async () => { + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitCommitDialogPage.stageEntityThenFileInQuickSuccession(burst2.name); + await expect.poll(async () => gitCommitDialogPage.getUnstagedChanges(), { timeout: DEFAULT_TIMEOUT }).toEqual([]); + }); + + const stagedFiles = await gitCommitDialogPage.getStagedChanges(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const finalStagedCards = await gitCommitDialogPage.getEntityCardNames(); + + expect(ignoredDiscard).toEqual({ cardVisible: true, actions: ['Discard', 'Stage'] }); + expect(confirmedDiscard.confirmLabel).toBe('Confirm'); + expect(confirmedDiscard.remainingCards.sort()).toEqual([ignored, burst1, burst2, burst3, discardedLater].map(request => request.name).sort()); + expect(confirmedDiscard.discardedUrl).toBe(discarded.url); + expect(confirmedDiscard.keptUrl).toBe(editedUrls.get(ignored.name)); + expect(afterFirstStage.unstagedCards.sort()).toEqual([burst1, burst2, burst3, discardedLater].map(request => request.name).sort()); + expect(afterFirstStage.unstagedActions).toEqual(['Discard', 'Stage']); + expect(afterFirstStage.files).toEqual({ staged: [fileName], unstaged: [fileName] }); + expect(afterFirstStage.stagedCards).toEqual([ignored.name]); + expect(afterFirstStage.stagedActions).toEqual(['Unstage']); + expect(afterBurst.unstagedCards.sort()).toEqual([burst2, burst3, discardedLater].map(request => request.name).sort()); + expect(afterBurst.stagedCards.sort()).toEqual([ignored, burst1].map(request => request.name).sort()); + expect(afterUnstageAndDiscard.stagedCards).toEqual([burst1.name]); + expect(afterUnstageAndDiscard.unstagedCards.sort()).toEqual([ignored, burst2, burst3].map(request => request.name).sort()); + expect(stagedFiles).toEqual([fileName]); + expect(finalStagedCards.sort()).toEqual([ignored, burst1, burst2, burst3].map(request => request.name).sort()); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/unknown-fields-are-preserved.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/unknown-fields-are-preserved.spec.ts new file mode 100644 index 0000000000..0a503770cb --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/unknown-fields-are-preserved.spec.ts @@ -0,0 +1,60 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; +import { buildSeedCollectionFile, buildSeedRequest } from './helpers'; + +test('Verify entity actions on one request keep a hand-added unknown field on another request and add no ghost change', async ({ user }) => { + test.fail(true, "INS-4077"); + + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const workspace = { id: `wrk_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10), environmentId: `env_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}` }; + const [first, second] = [ + { id: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }, + { id: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }, + ]; + const unknownValue = faker.string.alphanumeric(12); + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const seedFiles: Record = { + [fileName]: buildSeedCollectionFile(workspace, [ + buildSeedRequest(first.id, first.name, 'https://example.com/first'), + buildSeedRequest(second.id, second.name, 'https://example.com/second'), + ]), + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.writeFileSync(path.join(cloneDir, fileName), buildSeedCollectionFile(workspace, [ + buildSeedRequest(first.id, first.name, 'https://example.com/first-edited'), + buildSeedRequest(second.id, second.name, 'https://example.com/second', { unknownField: unknownValue }), + ])); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncFlow.stageEntity(fileName, first.name); + const indexAfterStage = execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' }); + await gitSyncFlow.unstageEntity(fileName, first.name); + await gitSyncFlow.discardEntity(fileName, first.name); + const cardsAfterDiscard = await gitCommitDialogPage.getEntityCardNames(); + const secondTabsAfterDiscard = await gitCommitDialogPage.getEntityCardTabs(second.name); + const fileAfterDiscard = fs.readFileSync(path.join(cloneDir, fileName), 'utf8'); + + expect(indexAfterStage).not.toContain(unknownValue); + expect(cardsAfterDiscard).toEqual([second.name]); + expect(secondTabsAfterDiscard).toEqual(['Settings 1']); + expect(fileAfterDiscard).toContain(unknownValue); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/vault-secrets-stay-masked.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/vault-secrets-stay-masked.spec.ts new file mode 100644 index 0000000000..3fca9127e4 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/vault-secrets-stay-masked.spec.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; + +test('Verify vault-backed secrets are listed by name and never shown in the clear in an environment card', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const buildEnvironmentFile = (name: string, data: Record, id: string) => + JSON.stringify( + { + type: 'environment.insomnia.rest/5.0', + schema_version: '5.1', + name, + meta: { id: `wrk_${id}`, created: 1000, modified: 1000, description: '' }, + environments: { name: 'Base Environment', meta: { id: `env_${id}`, created: 1000, modified: 1000, isPrivate: false }, data }, + }, + null, + 2, + ); + + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const environmentName = faker.string.alpha(10); + const secretName = faker.string.alpha(8); + const oldSecret = faker.string.alphanumeric(16); + const newSecret = faker.string.alphanumeric(20); + const plainName = faker.string.alpha(8); + const buildData = (plainValue: string, secretValue: string) => ({ + [plainName]: plainValue, + __insomnia_vault: { [secretName]: secretValue }, + }); + const id = faker.string.alphanumeric({ length: 16, casing: 'lower' }); + const seedFiles: Record = { + [fileName]: buildEnvironmentFile(environmentName, buildData('old', oldSecret), id), + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + fs.writeFileSync(path.join(cloneDir, fileName), buildEnvironmentFile(environmentName, buildData('new', newSecret), id)); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const tabs = await gitCommitDialogPage.getEntityCardTabs('Base Environment'); + const variablesText = await gitCommitDialogPage.getEntityCardText('Base Environment'); + await gitCommitDialogPage.selectEntityCardTab('Base Environment', 'Secrets'); + const secretsText = await gitCommitDialogPage.getEntityCardText('Base Environment'); + + expect(tabs).toEqual(['Variables 1', 'Secrets 1']); + expect(secretsText).toContain(secretName); + expect(secretsText).toContain('••••••'); + for (const text of [variablesText, secretsText]) { + expect(text).not.toContain(oldSecret); + expect(text).not.toContain(newSecret); + } + expect(variablesText).not.toContain(secretName); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/visual-view-shows-notice-instead-of-cards.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/visual-view-shows-notice-instead-of-cards.spec.ts new file mode 100644 index 0000000000..274f96297f --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/visual-view-shows-notice-instead-of-cards.spec.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; +import { expect } from "@playwright/test"; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { GitDiffView } from '../../../enums/git-diff-view'; +import { ProjectType } from '../../../enums/project-types'; +import { buildCollectionFile, DEFAULT_TIMEOUT, GIT_CREDENTIAL, pushServerFile, test } from "../../../misc/git-fixtures"; +import { Project } from '../../../models/project'; + +test('Verify the Visual view shows a notice instead of cards for a conflicted file, a file that does not match the Insomnia file format and non-Insomnia files', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, gitCommitDialogPage } = user.pageManager; + + const conflictFile = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const spectralFile = '.spectral.yaml'; + const openApiFile = `${faker.string.alpha(11).toLowerCase()}.yaml`; + const invalidFile = `${faker.string.alpha(12).toLowerCase()}.yaml`; + const original = buildCollectionFile(faker.string.alpha(10)); + const seedFiles: Record = { + [conflictFile]: original, + [spectralFile]: 'extends: spectral:oas\n', + [invalidFile]: buildCollectionFile(faker.string.alpha(10)), + [openApiFile]: `openapi: 3.0.0\ninfo:\n title: ${faker.string.alpha(10)}\n`, + }; + for (const [filePath, content] of Object.entries(seedFiles)) { + await pushServerFile({ path: filePath, content }); + } + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const cloneParentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-visual-diff-')); + await workspaceFlow.cloneGitProject(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name, { cloneParentDir }); + const cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0]); + const conflicted = `${original}<<<<<<< HEAD\n - url: https://ours.example.com\n=======\n - url: https://theirs.example.com\n>>>>>>> main\n`; + fs.writeFileSync(path.join(cloneDir, conflictFile), conflicted); + fs.appendFileSync(path.join(cloneDir, spectralFile), 'rules: {}\n'); + fs.writeFileSync(path.join(cloneDir, invalidFile), fs.readFileSync(path.join(cloneDir, invalidFile), 'utf8').replace('collection: []', 'collection: not-a-list')); + fs.appendFileSync(path.join(cloneDir, openApiFile), ` version: ${faker.string.numeric(3)}\n`); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(4); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, conflictFile); + const conflictNotice = await gitCommitDialogPage.getVisualDiffNotice(); + const conflictCards = await gitCommitDialogPage.getEntityCardNames(); + await gitCommitDialogPage.openTextViewFromNotice(); + const viewAfterConflictLink = await gitCommitDialogPage.getDiffView(); + const conflictContent = fs.readFileSync(path.join(cloneDir, conflictFile), 'utf8'); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, spectralFile); + const spectralNotice = await gitCommitDialogPage.getVisualDiffNotice(); + const spectralCards = await gitCommitDialogPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, invalidFile); + const invalidNotice = await gitCommitDialogPage.getVisualDiffNotice(); + const invalidCards = await gitCommitDialogPage.getEntityCardNames(); + const invalidWarningAbsent = await gitCommitDialogPage.isVisualDiffFormatWarningAbsent(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, openApiFile); + const openApiNotice = await gitCommitDialogPage.getVisualDiffNotice(); + await gitCommitDialogPage.openTextViewFromNotice(); + const viewAfterOpenApiLink = await gitCommitDialogPage.getDiffView(); + + expect(conflictNotice).toContain('Unable to parse this file for a visual diff.'); + expect(conflictCards).toEqual([]); + expect(conflictContent).toBe(conflicted); + for (const notice of [conflictNotice, spectralNotice, openApiNotice]) { + expect(notice).toContain('Open the Text view'); + } + for (const notice of [spectralNotice, openApiNotice]) { + expect(notice).toContain('No requests, folders, environments or settings changed'); + } + expect(invalidNotice).toContain("This is not a valid Insomnia file, so the Visual view cannot show it"); + expect(invalidNotice).toContain('Open the Text view'); + expect(invalidCards).toEqual([]); + expect(invalidWarningAbsent).toBe(true); + expect(spectralCards).toEqual([]); + expect([viewAfterConflictLink, viewAfterOpenApiLink]).toEqual([GitDiffView.Text, GitDiffView.Text]); +});