diff --git a/.claude/skills/write-insomnia-test/SKILL.md b/.claude/skills/write-insomnia-test/SKILL.md index cb657129e6..a971cc22dc 100644 --- a/.claude/skills/write-insomnia-test/SKILL.md +++ b/.claude/skills/write-insomnia-test/SKILL.md @@ -120,6 +120,8 @@ try { Save both files to the scratchpad directory (never under `tests/` — they're debug artifacts, not fixtures), then `Read` the HTML file and the screenshot right away as part of the same turn — don't just log that they were captured and move on. The point is to go straight from "it timed out" to a concrete diagnosis (missing `data-testid`, an unexpected dialog, a route that never resolved) in the same investigation pass, before deciding whether step 4's gap/bug call applies. +Electron screenshots often come back blank — read the failure's `error-context.md` (an ARIA snapshot of the page at the failure, in `test-results//`) instead, and redirect noisy runs to a file in the scratchpad (`npx playwright test … --reporter=list > $SCRATCH/out.txt 2>&1`, then `grep` it) because esbuild warnings drown the output. A throwaway probe may also `console.log` the card/DOM text and shell out to `git -C status/diff` to see what the app really wrote. + Skip this step when nothing about the real app's live behavior is in question. ### 7. Place and name the file @@ -127,7 +129,7 @@ Skip this step when nothing about the real app's live behavior is in question. - This step only applies once step 2 has concluded the scenario needs a genuinely new file — don't re-decide placement for something you're extending in place. - Main path (create → act → assert, the "everything works" case): `tests//happy-path.spec.ts`. If one already exists for that domain, do not overwrite it with a different scenario — add a new, separately named file instead (a same-intent extension belongs under step 2, not here). - Edge case / error scenario: `tests//.spec.ts` — name the file after the scenario itself (e.g. `unlinked-environment-variable.spec.ts`), never `test1.spec.ts` or similar. -- **A family of related specs for one feature/ticket** (several edge-case files that all drive the same scripted mock server, e.g. one per case of a test plan) may live in a subdirectory of the domain (`tests/mcp-client/error-response/`, `tests/git-sync/undiscardable-change/`) — one spec file per case, named after the scenario. Shared setup (a `connectTo(user, config)`-style helper, canned payloads, expected-title helpers) goes in a non-spec `helpers.ts` next to them (it isn't matched by the `*.spec.ts` test pattern); adjust relative imports by one extra `../`. Don't use this for a single spec — that stays a plain file under `tests//`. +- **A family of related specs for one feature/ticket** (several edge-case files that all drive the same scripted mock server, e.g. one per case of a test plan) may live in a subdirectory of the domain (`tests/mcp-client/error-response/`, `tests/git-sync/undiscardable-change/`, `tests/git-sync/visual-diff/`) — one spec file per case, named after the scenario. Shared setup (a `connectTo(user, config)`-style helper, canned payloads, expected-title helpers) goes in a non-spec `helpers.ts` next to them (it isn't matched by the `*.spec.ts` test pattern); adjust relative imports by one extra `../`. Don't use this for a single spec — that stays a plain file under `tests//`. Keep `helpers.ts` lean: a helper shorter than ~15 lines is written inline in the spec(s) that use it (a local `const` if reused within one test), and any `interface`/type describing test data goes to `models/` (one file per concept), never into `helpers.ts`. Before adding a spec to such a family, check whether an existing one can absorb the new test point (same setup, same assertion shape) — fold it in rather than adding a file; keep specs separate when one is `test.fail()`. - `tests/data/import/` and `tests/data/export/` sit together under `tests/data/` since both drive Preferences -> Data; `tests/data/import/` is further split by source: `file/`, `url/`, `curl/`, `clipboard/`, `mcp/`. - **If the test needs a fixture file** (a JSON/YAML/WSDL/etc. imported or read via `path.join(__dirname, "...")`), give the spec its own directory named after the spec file (without `.spec.ts`), and put the spec and every fixture it uses inside that directory together — e.g. `tests/data/import/file/import-open-api/import-open-api.spec.ts` + `tests/data/import/file/import-open-api/httpbingo-get.json`. Adjust the relative import depth to `misc/fixtures`/`enums/`/`models/` by one extra `../` versus a plain domain-level spec, since the file now sits one directory deeper. A spec with no fixture stays a plain file directly under `tests//` (or `tests/data/import//`) — don't create a directory for it. @@ -234,7 +236,7 @@ Real example: `tests/document/happy-path.spec.ts`. - [ ] No file outside `tests/` was created or modified, unless it was a confirmed bug fix under the Scope Constraint's exception — any *gap* found in `flows/`/`pages/`/`models/` was reported to the user, not patched. - [ ] If a framework bug was fixed: it was confirmed as broken (not merely unsupported) via a probe, the fix was minimal and targeted at that bug only (no unrelated refactors/new methods), the probe was re-run to confirm the fix, and the bug + fix were called out to the user as their own item. - [ ] If the test documents a real Insomnia *app* bug (not a framework bug, per §9a): it asserts the expected/correct behavior (never the current buggy one), starts with `test.fail();`, and confirmed via a real run that it reports as an expected failure ("✘" but counted as passed), not a hard failure. -- [ ] `import { expect, test }` comes from the relative path to `misc/fixtures` (not `@playwright/test`) — except Git Sync specs, which import `test`/`expect`/`GIT_CREDENTIAL`/`pushServerFile`/`buildCollectionFile`/`getServerBranches`/`getServerCommits` from `misc/git-fixtures` (see `reference.md`). +- [ ] `import { expect, test }` comes from the relative path to `misc/fixtures` (not `@playwright/test`) — except Git Sync specs, which import `test`/`expect`/`GIT_CREDENTIAL`/`pushServerFile`/`buildCollectionFile`/`getServerBranches`/`getServerCommits` from `misc/git-fixtures` (see `reference.md`). A helper `helpers.ts` next to them still takes `HTTP_SERVER`/`UserSession` from `misc/fixtures` (git-fixtures doesn't re-export them). - [ ] The test function takes only `{ user }` — no `workspaceFlow`/`pageManager`/other per-flow fixture in the parameter list. - [ ] Every Flow/Page method and every Model field used actually exists in `reference.md` or the real source file — nothing invented. - [ ] Every direct `pageManager.xxxPage` call was necessary because no Flow method covers that action (per "Prefer Flow methods over direct Page access") — never a Page call standing in for a step a Flow method already does. diff --git a/.claude/skills/write-insomnia-test/reference.md b/.claude/skills/write-insomnia-test/reference.md index 50d577ac63..3db73b02e8 100644 --- a/.claude/skills/write-insomnia-test/reference.md +++ b/.claude/skills/write-insomnia-test/reference.md @@ -33,7 +33,7 @@ Every test injects only the `user` fixture (`async ({ user }) => {...}`) — nev | Plugins & Script Sandbox (Preferences → Plugins/Scripting tabs, `tests/plugin/`) | `preferencesFlow` (no separate PluginsFlow/PluginsPage) | `flows/preferences.flow.ts` | `pages/preferences.page.ts` | `models/settings.ts`'s `pluginSandboxEnabled`; `enums/script-sandbox-rule-group.ts` | | Konnect sidebar sync (`tests/konnect/`) | none — call `user.pageManager.konnectPage` directly, no KonnectFlow | none | `pages/konnect.page.ts` | none | | Template Tags (Insert/Edit Tag modal, Live Preview) | `templateTagFlow` | `flows/template-tag.flow.ts` | `pages/template-tag.page.ts` | `models/template-tag.ts` | -| Git Sync (branch/commit/history/push, `tests/git-sync/`) | `gitSyncFlow` (+ `preferencesFlow.addGitCredential`, `workspaceFlow.create`/`.openSettings` for cloning/settings/adopting a local folder) | `flows/git-sync.flow.ts`, `flows/workspace.flow.ts` | `pages/git-sync.page.ts` (connected-project toolbar/modals), `pages/project-settings.page.ts` (create/settings dialog's Git setup form + repo relocation + "Open local folder" sub-form) | `models/commit.ts`, `models/git-credential.ts`, `models/git-repo-connection.ts` | +| Git Sync (branch/commit/history/push, `tests/git-sync/`) | `gitSyncFlow` (+ `preferencesFlow.addGitCredential`, `workspaceFlow.create`/`.openSettings` for cloning/settings/adopting a local folder) | `flows/git-sync.flow.ts`, `flows/workspace.flow.ts` | `pages/git-sync.page.ts` (connected-project toolbar/modals, incl. the Commit modal's Visual (Beta) diff cards), `pages/project-settings.page.ts` (create/settings dialog's Git setup form + repo relocation + "Open local folder" sub-form) | `models/commit.ts`, `models/git-credential.ts`, `models/git-repo-connection.ts`, `models/git-entity-type-files.ts` / `git-file-level-settings.ts` / `git-moved-request.ts` / `git-long-values.ts` (Visual-diff file plans) | | Cloud Sync (VCS-backed workspace sync, `tests/cloud-sync/`) | `cloudSyncFlow` (openProjectDashboard/fetch/discardAllChanges/commitAndPush/restoreSnapshot/createBranch/mergeBranch/delete/deleteUnsyncedFile) — Branches-modal actions (fetch remote branch/checkout/delete/create) and a few polling/re-navigation/one-off-action helpers (`reselect`/`waitForPullAvailable`/`clickPull`/`closeSyncMenu`/`isCommitDisabled`) have no Flow wrapper, call `pageManager.cloudSyncPage` directly since a whole modal-session needs to stay open across several of them or the test needs to read a live value rather than fire-and-forget an action | `flows/cloud-sync.flow.ts` | `pages/cloud-sync.page.ts` | `enums/delete-mode.ts` (`DeleteMode`), `enums/sync-status.ts` (`SyncStatus`) — otherwise plain strings/booleans in, plain booleans out | | App lifecycle (main process, not a UI domain) | `appFlow` | `flows/app.flow.ts` | none — reaches the main process directly via `flowManager.electronApp`, no DOM involved | none | | Login / Scratch Pad entry points (`tests/app/scratch-pad-mode.spec.ts`) | none — call `user.pageManager.workspacePage` directly (`logOut()`/`clickUseLocalScratchPad()`/`isLoginEntryPointVisible()`/`isUnlockFullFeaturesVisible()`), no dedicated flow | none | `pages/workspace.page.ts` | none | @@ -1270,6 +1270,45 @@ Always pass `GitChangeList.Staged`/`.Unstaged` (never the raw "Staged changes" s - To modify a file on disk (e.g. a non-Insomnia yaml), pass `{ cloneParentDir: fs.mkdtempSync(path.join(os.tmpdir(), "…")) }` as the 3rd arg of the Git `workspaceFlow.create()`; the clone is the single sub-directory inside it (`path.join(dir, fs.readdirSync(dir)[0])`). Added/deleted files are easiest via the UI (`workspaceFlow.create(project, collection, fileName)` / `workspaceFlow.delete(collection)`). - After a discard of an undiscardable file the change reappears asynchronously — poll `getChanges().unstaged` for it rather than reading once. +**Visual (Beta) diff — per-entity Stage / Unstage / Discard (INS-3667, specs in `tests/git-sync/visual-diff/`, a family directory with a shared `helpers.ts`).** The Commit modal's diff pane has a Text / Visual (Beta) switch; Visual shows one card per changed entity (request, folder, environment, WebSocket, Socket.IO, gRPC, MCP, mock route, cookie jar) plus file-level cards (Workspace, API Spec, Unit Tests, CA Certificates, Mock Server). All of these need `openCommitDialog()`/`openVisualDiff()` first and leave the modal **open** afterwards — never call `gitSyncFlow.getChanges()` (it re-opens the modal and times out) while it is open; read `gitSyncPage.getStagedChanges()/getUnstagedChanges()` instead, and `closeCommitDialog()` before `hasUncommittedChanges()` (the Git Sync menu is blocked by the modal). + +```ts +// enums/git-diff-view.ts — GitDiffView.Text = "Text" | GitDiffView.Visual = "Visual (Beta)" +// GitSyncFlow +openVisualDiff(list: GitChangeList, fileName: string): Promise // opens the modal if needed, selects the file in that list, switches to Visual +stageEntity(fileName, entityName, typeLabel?): Promise // Unstaged side: Stage on a card, waits for the card to leave +unstageEntity(fileName, entityName, typeLabel?): Promise // Staged side: Unstage +discardEntity(fileName, entityName, typeLabel?): Promise // Unstaged side: Discard + Confirm click +// GitSyncPage (no Flow wrapper) — card = `div.border-l-2` located by its header name (`span.font-semibold`, exact) +switchDiffView(view) / getDiffView() / hasDiffViewOption(view) +getEntityCardNames(): Promise // waits for cards or a notice; [] when a notice is shown instead +getEntityCardActions(name, typeLabel?) // e.g. ["Discard","Stage"] (Unstaged), ["Unstage"] (Staged), [] (Workspace identity card) +getEntityCardTabs(name, typeLabel?) // e.g. ["Headers 1","Secrets 1"] (label + count) +selectEntityCardTab(name, tab, typeLabel?) / getEntityCardText(name, typeLabel?) / getEntityCardHeader(name) // {badge,name,url,onOneLine} +getEntityCardCompactDiff(name, tab?, typeLabel?) // Monaco compact diff: {editors, foldedRegions, drawnLines}; hasEntityCardCompactDiff() +startEntityDiscard / getEntityDiscardConfirmLabel / confirmEntityDiscard / waitForEntityDiscardPromptToLapse / isEntityCardVisible +stageEntitiesInQuickSuccession(names) // clicks Stage on several cards in ONE browser task (tests the busy guard) +stageEntityThenFileInQuickSuccession(name) // entity Stage then "Stage this file" in ONE browser task +getVisualDiffNotice() / openTextViewFromNotice() / getEntitySummary() / getErrorToastText() +``` + +- `typeLabel` is only needed when several cards share a name: the file-level cards (Workspace, API Spec, Unit Tests, CA Certificates) all carry the **workspace name** — pass the badge text ("Workspace", "API Spec", "Unit Tests", "CA Certificates"). The Mock Server card is named by its URL. The default environment card is "Base Environment", the cookie jar "Default Jar". +- **Card names follow the side being viewed.** The Staged view derives names from the *index*, the Unstaged view from the *working file*: after staging only some of a renamed workspace's cards, the staged cards still carry the OLD workspace name. Act on the "Workspace" card last. +- Discard is a `PromptButton`: the first click only turns it into "Confirm" (or "Confirm (discards its contents too)" for a new folder with contents) for ~2 s, and in that state its accessible name is **not** "Confirm" — locate it with `locator('button').filter({ hasText: /^Confirm/ })`, never `getByRole('button', { name: /^Confirm/ })`. +- After a card action the file can sit in **both** lists. When the last change of a file is staged/discarded it leaves the list; a formatting-only remainder (the staged index is re-serialised as canonical YAML) shows the "No requests, folders, environments or settings changed…" notice. + +**Authoring multi-entity diffs: UI-built vs disk-authored.** Use the UI (each spec inlines it: `preferencesFlow.addGitCredential` → Git `workspaceFlow.create(project, collection, baseName)` → `httpRequestFlow.create(collection, …)` → `gitSyncFlow.commit()`, then edit with `httpRequestFlow.get(request)` + `httpRequestPage.setUrl()`, `folderFlow.setScripts()` or `environmentPage.setVariables()`) for request / folder / environment scenarios. For entity types with no usable flow (WebSocket, Socket.IO, gRPC, MCP, mock route, cookie jar, API spec, unit tests, certificates, vault secrets, conflict markers) **write the file on disk**: each spec inlines it — `pushServerFile({ path, content: v1 })`, `preferencesFlow.addGitCredential(GIT_CREDENTIAL)`, a Git `workspaceFlow.create(project, GIT_CREDENTIAL.name, { cloneParentDir })` with `cloneParentDir = fs.mkdtempSync(...)`, `cloneDir = path.join(cloneParentDir, fs.readdirSync(cloneParentDir)[0])`, then `fs.writeFileSync(path.join(cloneDir, file), v2)` overwrites it — the Visual view diffs raw file text (JSON is valid YAML, so builders `JSON.stringify` their objects). `helpers.ts` only keeps the larger plan builders `planEntityTypeFiles(): GitEntityTypeFiles`, `planFileLevelSettings(): GitFileLevelSettings`, `planMovedRequest(): GitMovedRequest`, `planLongValues(): GitLongValues` — their return types live in `models/git-entity-type-files.ts`, `models/git-file-level-settings.ts`, `models/git-moved-request.ts`, `models/git-long-values.ts` (a `{ names…, build(changed) }` shape returning the v1/v2 file text); short helpers (writing/reading the clone file, `git show :file`, id generation, request/folder/collection/environment file builders) are written inline in each spec as local consts. Hard-won rules for disk files: + - v1 and v2 must reuse **the same ids and names** — generate them once and have `build(changed)` close over them (calling `faker` inside `build` makes every entity "modified"). + - **Racy git:** a v2 with the same byte length as v1, written within the same second as the clone, is invisible to the app's status check — always change the length (e.g. a longer value or an added header). + - Entity type comes from the id prefix: `req_` request, `fld_` folder, `ws-req_` WebSocket, `socketio-req_` Socket.IO, `greq_` gRPC, `mock-route_` route, `mcp-req_` MCP (file type `mcpClient.insomnia/5.0`, key `mcpRequest`), mock file type `mock.insomnia.rest/5.0` (needs `server` + `routes`), design file `spec.insomnia.rest/5.0` (`spec: { contents }`, `testSuites`, `certificates`). + - To make a card disappear after Stage, the working file must already equal what staging writes (a canonical re-serialisation that **fills defaults**): include `meta.isPrivate: false` + `settings: {...}` on requests and `meta.isPrivate: false` on environments, **omit** `isPrivate` on mock servers, and give mock routes `statusCode: 200` + `mimeType: application/json`. Otherwise the card stays with a spurious "Settings" change. + - Rewriting an Insomnia file with only metadata (timestamps/reordering) is silently reverted by the app, so it cannot produce a "formatting-only" diff; use non-Insomnia YAML (`.spectral.yaml`, an OpenAPI file) for the "no entity changes" notice. + - Vault secrets live at `data.__insomnia_vault` of a **shared** environment file (private sub-environments are never exported to git, and the raw JSON editor rejects that key) — seed them on disk; values render as `••••••`. + - After a discard the app re-imports the file and the environment editor opens in raw JSON view: read values back with `environmentPage.selectEnvironment(name)` + `getRawJson()`, not `environmentFlow.get()`. + - Spec-local canonical builders (`buildRequest` / `buildFolder` / `buildCollectionFile`) already carry the default fields staging writes (`meta.isPrivate: false`, request `settings`), so cards vanish after Stage; check staged content with `execSync('git show :')` / `git diff --cached -U0` in the clone dir. + - **UI-built cards for non-HTTP types (no framework change needed):** WebSocket / Socket.IO / gRPC `create()` take the parent's *first* child as the new request, so create each in its **own empty collection file** (an HTTP request or a folder created first breaks it). Folders: create them last in a collection. gRPC `create()` cannot be used (it waits for a method tab that needs a proto file). Mock route: `mockServerFlow.create(project, new MockServer(name, MOCK_BIN_SERVER))` + `createRoute()` with status/body only (route `headers` is a known failing area, see `tests/mock-server/auto-push.spec.ts`); MCP: `mcpClientFlow.create(project, new McpClient(name, MCP_SERVER))`. Mock and MCP workspaces are their own file, named `.yaml`. `httpRequestPage.setParams()/setHeaders()` **append** to the existing rows, so edit by adding new names. Auth: only OAuth 2.0 can be set through the flows. Request **Docs / Settings** tabs, a gRPC card, Cookie Jar cookies, environment variable order and the collapsed-chip view have no usable framework method. + - **Known expected failures (`test.fail(true, "INS-…")`):** `discard-last-entity-of-new-file` INS-4076 (the auto-created Base Environment / Default Jar come straight back after Discard, so a new file never leaves Unstaged), `unknown-fields-are-preserved` INS-4077 (Discard rewrites the file from the DB and drops a hand-added unknown field), `stage-in-file-with-non-canonical-head` INS-4078 (staging one request also rewrites folder order / defaults of other entities), `file-stage-during-entity-action` INS-4079 ("Stage this file" clicked during a pending entity action is silently lost). + `Commit` (`models/commit.ts`): `{ message: string; id?: string }` — positional constructor (`new Commit(message)`), `id` populated after `commit()`/`commitAndPush()` read it back from the History modal's row `data-key` (the real git OID). **Creating a Git Sync project and its collections** goes through `workspaceFlow.create()`'s dedicated overloads, not a separate flow: diff --git a/packages/insomnia-test/enums/git-diff-view.ts b/packages/insomnia-test/enums/git-diff-view.ts new file mode 100644 index 0000000000..97a4211159 --- /dev/null +++ b/packages/insomnia-test/enums/git-diff-view.ts @@ -0,0 +1,4 @@ +export enum GitDiffView { + Text = "Text", + Visual = "Visual (Beta)", +} diff --git a/packages/insomnia-test/flows/git-sync.flow.ts b/packages/insomnia-test/flows/git-sync.flow.ts index 68c4a37742..cb16294594 100644 --- a/packages/insomnia-test/flows/git-sync.flow.ts +++ b/packages/insomnia-test/flows/git-sync.flow.ts @@ -1,4 +1,5 @@ import { GitChangeList } from "../enums/git-change-list"; +import { GitDiffView } from "../enums/git-diff-view"; import { Commit } from "../models/commit"; import { BaseFlow } from "./base.flow"; @@ -143,6 +144,59 @@ export class GitSyncFlow extends BaseFlow { await gitSyncPage.closeCommitDialog(); } + /** + * Opens the Commit modal (if it isn't already open), selects a file in + * the given list and switches its diff pane to the Visual (Beta) view. + * @param list - Which list the file is in + * @param fileName - The file name as listed in the modal, e.g. "foo.yaml" + */ + async openVisualDiff(list: GitChangeList, fileName: string): Promise { + const { gitSyncPage } = this.pageManager; + if (!(await gitSyncPage.isCommitDialogOpen())) { + await gitSyncPage.openCommitDialog(); + } + await gitSyncPage.openChangeDiff(list, fileName); + await gitSyncPage.switchDiffView(GitDiffView.Visual); + } + + /** + * Stages one entity from its card in the Visual view of an unstaged file. + * @param fileName - The file name as listed in the modal, e.g. "foo.yaml" + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async stageEntity(fileName: string, entityName: string, typeLabel?: string): Promise { + const { gitSyncPage } = this.pageManager; + await this.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.stageEntity(entityName, typeLabel); + } + + /** + * Unstages one entity from its card in the Visual view of a staged file. + * @param fileName - The file name as listed in the modal, e.g. "foo.yaml" + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async unstageEntity(fileName: string, entityName: string, typeLabel?: string): Promise { + const { gitSyncPage } = this.pageManager; + await this.openVisualDiff(GitChangeList.Staged, fileName); + await gitSyncPage.unstageEntity(entityName, typeLabel); + } + + /** + * Discards one entity from its card in the Visual view of an unstaged + * file, confirming the prompt. + * @param fileName - The file name as listed in the modal, e.g. "foo.yaml" + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async discardEntity(fileName: string, entityName: string, typeLabel?: string): Promise { + const { gitSyncPage } = this.pageManager; + await this.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.startEntityDiscard(entityName, typeLabel); + await gitSyncPage.confirmEntityDiscard(entityName, typeLabel); + } + /** * Discards every uncommitted local change. */ diff --git a/packages/insomnia-test/models/git-entity-type-files.ts b/packages/insomnia-test/models/git-entity-type-files.ts new file mode 100644 index 0000000000..424808e646 --- /dev/null +++ b/packages/insomnia-test/models/git-entity-type-files.ts @@ -0,0 +1,14 @@ +export interface GitEntityTypeNames { + folder: string; + request: string; + webSocket: string; + socketIo: string; + grpc: string; + mockRoute: string; + mcp: string; +} + +export interface GitEntityTypeFiles { + names: GitEntityTypeNames; + build: (changed: boolean) => { collection: string; mock: string; mcp: string }; +} diff --git a/packages/insomnia-test/models/git-file-level-settings.ts b/packages/insomnia-test/models/git-file-level-settings.ts new file mode 100644 index 0000000000..e9b6c26262 --- /dev/null +++ b/packages/insomnia-test/models/git-file-level-settings.ts @@ -0,0 +1,11 @@ +export interface GitFileLevelSettingsNames { + workspace: string; + previousWorkspace: string; + mockServerUrl: string; + mockWorkspace: string; +} + +export interface GitFileLevelSettings { + names: GitFileLevelSettingsNames; + build: (changed: boolean) => { spec: string; mock: string }; +} diff --git a/packages/insomnia-test/models/git-long-values.ts b/packages/insomnia-test/models/git-long-values.ts new file mode 100644 index 0000000000..c18a765ed4 --- /dev/null +++ b/packages/insomnia-test/models/git-long-values.ts @@ -0,0 +1,7 @@ +export interface GitLongValues { + longRequest: string; + shortRequest: string; + workspace: string; + totalLines: number; + build: (changed: boolean) => string; +} diff --git a/packages/insomnia-test/models/git-moved-request.ts b/packages/insomnia-test/models/git-moved-request.ts new file mode 100644 index 0000000000..7bfe7f8501 --- /dev/null +++ b/packages/insomnia-test/models/git-moved-request.ts @@ -0,0 +1,6 @@ +export interface GitMovedRequest { + requestId: string; + requestName: string; + folderName: string; + build: (moved: boolean) => string; +} diff --git a/packages/insomnia-test/pages/git-sync.page.ts b/packages/insomnia-test/pages/git-sync.page.ts index e9ba472159..3fef5846f9 100644 --- a/packages/insomnia-test/pages/git-sync.page.ts +++ b/packages/insomnia-test/pages/git-sync.page.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test'; import { GitChangeList } from '../enums/git-change-list'; +import { GitDiffView } from '../enums/git-diff-view'; import { DEFAULT_TIMEOUT } from '../misc/constants'; import { BasePage } from './base.page'; @@ -30,6 +31,17 @@ export class GitSyncPage extends BasePage { 'This change is only Insomnia internally reformatting the file', ); + private readonly entityCard = (entityName: string, typeLabel?: string) => { + const card = this.commitDialog + .locator('div.border-l-2') + .filter({ has: this.page.locator('span.font-semibold').getByText(entityName, { exact: true }) }); + return typeLabel ? card.filter({ has: this.page.getByText(typeLabel, { exact: true }) }) : card; + }; + + private readonly visualDiffNotice = this.commitDialog.getByText( + /Unable to parse this file for a visual diff|No requests, folders, environments or settings changed/, + ); + private readonly changeRow = (list: GitChangeList, fileName: string) => this.commitDialog .getByRole('grid', { name: list, exact: true }) @@ -301,6 +313,317 @@ export class GitSyncPage extends BasePage { ).toBeVisible({ timeout: DEFAULT_TIMEOUT }); } + /** + * Switches the open Commit modal's diff pane between the Text and Visual + * (Beta) views. Requires `openChangeDiff()` to have been called first. + * @param view - The view to show + */ + async switchDiffView(view: GitDiffView): Promise { + await this.commitDialog.getByRole('button', { name: view, exact: true }).click(); + } + + /** + * Reads which diff view the open Commit modal's diff pane is showing, + * from the view switch's highlighted button. + * @returns The active view + */ + async getDiffView(): Promise { + const visualClass = (await this.commitDialog.getByRole('button', { name: GitDiffView.Visual, exact: true }).getAttribute('class')) ?? ''; + return visualClass.includes('bg-(--hl-sm)') ? GitDiffView.Visual : GitDiffView.Text; + } + + /** + * Reads whether the diff pane's view switch offers a given view. + * @param view - The view to look for + * @returns True if a button with that label is visible + */ + async hasDiffViewOption(view: GitDiffView): Promise { + return this.commitDialog.getByRole('button', { name: view, exact: true }).isVisible(); + } + + /** + * Waits for the Visual view to settle (cards, or one of its notices) and + * reads every entity card's name, in display order. Requires the Visual + * view to be showing. + * @returns The entity names, one per card + */ + async getEntityCardNames(): Promise { + await this.commitDialog + .locator('div.border-l-2') + .first() + .or(this.visualDiffNotice) + .waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + const cards = this.commitDialog.locator('div.border-l-2'); + const count = await cards.count(); + const result: string[] = []; + for (let i = 0; i < count; i++) { + result.push((await cards.nth(i).locator('span.font-semibold').first().innerText()).trim()); + } + return result; + } + + /** + * Reads the action buttons shown on an entity's card, e.g. ["Discard", "Stage"]. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns The buttons' labels + */ + async getEntityCardActions(entityName: string, typeLabel?: string): Promise { + const labels = await this.entityCard(entityName, typeLabel).locator('button').allInnerTexts(); + return labels.map(label => label.trim()).filter(label => /^(Stage|Unstage|Discard|Confirm.*)$/.test(label)); + } + + /** + * Reads the full text of an entity's card (header, tabs, field diffs). + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns The card's text + */ + async getEntityCardText(entityName: string, typeLabel?: string): Promise { + return (await this.entityCard(entityName, typeLabel).innerText()).trim(); + } + + /** + * Reads the change-section tab labels on an entity's card, e.g. ["Params 1", "Headers 2"]. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns The tab labels + */ + async getEntityCardTabs(entityName: string, typeLabel?: string): Promise { + const labels = await this.entityCard(entityName, typeLabel).getByRole('tab').allInnerTexts(); + return labels.map(label => label.replaceAll('\n', ' ').trim()); + } + + /** + * Selects a change-section tab on an entity's card. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @param tab - The tab's label without its count, e.g. "Secrets" + */ + async selectEntityCardTab(entityName: string, tab: string, typeLabel?: string): Promise { + await this.entityCard(entityName, typeLabel).getByRole('tab', { name: new RegExp(`^\\s*${tab}`) }).click(); + } + + /** + * Reads a card's header: its method badge, name and URL texts, and + * whether the three share one line (their boxes overlap vertically). + * Only meaningful for cards with a badge and URL, e.g. a request's. + * @param entityName - The entity's name as shown on its card + * @returns The header's texts and whether they sit on one line + */ + async getEntityCardHeader(entityName: string): Promise<{ badge: string; name: string; url: string; onOneLine: boolean }> { + const header = this.entityCard(entityName).locator('div.items-baseline').first(); + const badge = header.locator('span').nth(0); + const name = header.locator('span.font-semibold'); + const url = header.locator('span.font-mono'); + const boxes = [await badge.boundingBox(), await name.boundingBox(), await url.boundingBox()]; + const onOneLine = boxes.every( + box => box !== null && box.y < boxes[1]!.y + boxes[1]!.height && box.y + box.height > boxes[1]!.y, + ); + return { + badge: (await badge.innerText()).trim(), + name: (await name.innerText()).trim(), + url: (await url.innerText()).trim(), + onOneLine, + }; + } + + /** + * Scrolls an entity card's (optionally tab-selected) long-value diff into + * view, waits for its lazily created Monaco diff editor, and reads how + * it rendered: how many editors, how many folded unchanged regions, and + * how many lines the changed (right) side actually draws. + * @param entityName - The entity's name as shown on its card + * @param tab - A change-section tab to select first, e.g. "Body"; omit for the card's current tab + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns The compact diff's editor count, folded region count and drawn line count + */ + async getEntityCardCompactDiff( + entityName: string, + tab?: string, + typeLabel?: string, + ): Promise<{ editors: number; foldedRegions: number; drawnLines: number }> { + const card = this.entityCard(entityName, typeLabel); + if (tab) { + await this.selectEntityCardTab(entityName, tab, typeLabel); + } + const editor = card.locator('.monaco-diff-editor').first(); + await editor.scrollIntoViewIfNeeded(); + await expect(editor).toBeVisible({ timeout: DEFAULT_TIMEOUT }); + await expect(editor.locator('.diff-hidden-lines').first()).toBeAttached({ timeout: DEFAULT_TIMEOUT }); + return { + editors: await card.locator('.monaco-diff-editor').count(), + foldedRegions: await editor.locator('.diff-hidden-lines').count(), + drawnLines: await editor.locator('.modified .view-line').count(), + }; + } + + /** + * Reads whether an entity card currently shows a compact Monaco diff + * (long values) rather than the plain inline before/after form. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns True if the card contains a compact diff editor + */ + async hasEntityCardCompactDiff(entityName: string, typeLabel?: string): Promise { + return (await this.entityCard(entityName, typeLabel).locator('.monaco-diff-editor').count()) > 0; + } + + /** + * Reads the Visual view's "N entities changed" summary line. + * @returns The summary text, e.g. "3 entities changed" + */ + async getEntitySummary(): Promise { + return (await this.commitDialog.getByText(/^\d+ (entity|entities) changed$/).innerText()).trim(); + } + + /** + * Stages one entity from its card in the Visual view (Unstaged side) and + * waits for its card to leave the list. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async stageEntity(entityName: string, typeLabel?: string): Promise { + await this.entityCard(entityName, typeLabel).getByRole('button', { name: 'Stage', exact: true }).click(); + await expect(this.entityCard(entityName, typeLabel)).toBeHidden({ timeout: DEFAULT_TIMEOUT }); + } + + /** + * Clicks Stage on several entities' cards within one browser task, i.e. + * before the Visual view can re-render in between — the way a fast + * double click would — then waits for the first entity's card to leave + * the list. Every click after the first lands while its action is still + * pending. + * @param entityNames - The entities' names as shown on their cards, first one wins + */ + async stageEntitiesInQuickSuccession(entityNames: string[]): Promise { + await this.commitDialog.evaluate((root, names) => { + for (const name of names) { + const card = [...root.querySelectorAll('div.border-l-2')].find(candidate => + [...candidate.querySelectorAll('span.font-semibold')].some(span => span.textContent?.trim() === name), + ); + const button = [...(card?.querySelectorAll('button') ?? [])].find(candidate => candidate.textContent?.trim() === 'Stage'); + button?.click(); + } + }, entityNames); + await expect(this.entityCard(entityNames[0])).toBeHidden({ timeout: DEFAULT_TIMEOUT }); + } + + /** + * Clicks Stage on an entity's card and then "Stage this file" within one + * browser task, so the file-level action fires while the entity action is + * still pending. + * @param entityName - The entity's name as shown on its card + */ + async stageEntityThenFileInQuickSuccession(entityName: string): Promise { + await this.commitDialog.evaluate((root, name) => { + const card = [...root.querySelectorAll('div.border-l-2')].find(candidate => + [...candidate.querySelectorAll('span.font-semibold')].some(span => span.textContent?.trim() === name), + ); + const entityButton = [...(card?.querySelectorAll('button') ?? [])].find(candidate => candidate.textContent?.trim() === 'Stage'); + const fileButton = [...root.querySelectorAll('button')].find(candidate => candidate.textContent?.trim() === 'Stage this file'); + entityButton?.click(); + fileButton?.click(); + }, entityName); + } + + /** + * Unstages one entity from its card in the Visual view (Staged side) and + * waits for its card to leave the list. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async unstageEntity(entityName: string, typeLabel?: string): Promise { + await this.entityCard(entityName, typeLabel).getByRole('button', { name: 'Unstage', exact: true }).click(); + await expect(this.entityCard(entityName, typeLabel)).toBeHidden({ timeout: DEFAULT_TIMEOUT }); + } + + /** + * Clicks Discard on an entity's card, without confirming. The button + * turns into a confirmation prompt that lapses after about 2 seconds. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async startEntityDiscard(entityName: string, typeLabel?: string): Promise { + const confirmButton = this.entityCard(entityName, typeLabel).locator('button').filter({ hasText: /^Confirm/ }); + await expect(async () => { + await this.entityCard(entityName, typeLabel).getByRole('button', { name: 'Discard', exact: true }).click(); + await expect(confirmButton).toBeVisible({ timeout: 1000 }); + }).toPass({ timeout: DEFAULT_TIMEOUT }); + } + + /** + * Reads the label of the confirmation prompt shown after + * `startEntityDiscard()`, e.g. "Confirm" or "Confirm (discards its contents too)". + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns The prompt's label + */ + async getEntityDiscardConfirmLabel(entityName: string, typeLabel?: string): Promise { + return (await this.entityCard(entityName, typeLabel).locator('button').filter({ hasText: /^Confirm/ }).innerText()).trim(); + } + + /** + * Confirms a pending entity discard and waits for its card to leave the list. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async confirmEntityDiscard(entityName: string, typeLabel?: string): Promise { + await this.entityCard(entityName, typeLabel).locator('button').filter({ hasText: /^Confirm/ }).click(); + await expect(this.entityCard(entityName, typeLabel)).toBeHidden({ timeout: DEFAULT_TIMEOUT }); + } + + /** + * Waits for an entity's confirmation prompt to lapse back to "Discard". + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + */ + async waitForEntityDiscardPromptToLapse(entityName: string, typeLabel?: string): Promise { + await expect(this.entityCard(entityName, typeLabel).getByRole('button', { name: 'Discard', exact: true })).toBeVisible({ + timeout: DEFAULT_TIMEOUT, + }); + } + + /** + * Reads whether an entity has a card in the Visual view right now. + * @param entityName - The entity's name as shown on its card + * @param typeLabel - The card's type badge (e.g. "API Spec"), only needed when several cards share a name + * @returns True if its card is visible + */ + async isEntityCardVisible(entityName: string, typeLabel?: string): Promise { + return this.entityCard(entityName, typeLabel).isVisible(); + } + + /** + * Reads the notice the Visual view shows instead of cards ("Unable to + * parse this file for a visual diff." or "No requests, folders, + * environments or settings changed…"). Waits for it to appear. + * @returns The notice text + */ + async getVisualDiffNotice(): Promise { + const notice = this.visualDiffNotice.locator('xpath=..'); + await expect(notice).toBeVisible({ timeout: DEFAULT_TIMEOUT }); + return (await notice.innerText()).replaceAll('\n', ' ').trim(); + } + + /** + * Clicks the "Open the Text view" link in a Visual view notice. + */ + async openTextViewFromNotice(): Promise { + await this.commitDialog.getByRole('button', { name: 'Open the Text view' }).click(); + } + + /** + * Reads the title and description of the error toast an entity action + * raises ("Failed to stage/unstage/discard changes"). + * @returns The toast's text + */ + async getErrorToastText(): Promise { + const toast = this.page.getByText(/Failed to (stage|unstage|discard) changes/); + await expect(toast).toBeVisible({ timeout: DEFAULT_TIMEOUT }); + return (await toast.innerText()).trim(); + } + /** * Reads whether the open Commit modal's diff pane shows the "can't be * discarded" warning. Requires `openChangeDiff()` to have been called. diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/clicks-during-pending-action-are-ignored.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/clicks-during-pending-action-are-ignored.spec.ts new file mode 100644 index 0000000000..7450dec1db --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/clicks-during-pending-action-are-ignored.spec.ts @@ -0,0 +1,56 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 clicks during a pending action are ignored and consecutive actions do not overwrite each other', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 4; 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 [first, second, third, fourth] = requests; + for (const request of requests) { + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alphanumeric(10)}`); + } + + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.stageEntitiesInQuickSuccession([first.name, second.name, third.name]); + const unstagedAfterBurst = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedAfterBurst = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.stageEntity(fileName, second.name); + await gitSyncFlow.stageEntity(fileName, third.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedAtEnd = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const unstagedAtEnd = await gitSyncPage.getEntityCardNames(); + + expect(unstagedAfterBurst.sort()).toEqual([second.name, third.name, fourth.name].sort()); + expect(stagedAfterBurst).toEqual([first.name]); + expect(stagedAtEnd.sort()).toEqual([first.name, second.name, third.name].sort()); + expect(unstagedAtEnd).toEqual([fourth.name]); +}); 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..c7515c575d --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/dedicated-card-for-every-entity-type.spec.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, 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', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = 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.create(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, files.collection), changed.collection); + fs.writeFileSync(path.join(cloneDir, files.mock), changed.mock); + fs.writeFileSync(path.join(cloneDir, files.mcp), changed.mcp); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(3); + + 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 gitSyncPage.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 gitSyncPage.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'], + }); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/deleted-folder-is-restored-asymmetrically.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/deleted-folder-is-restored-asymmetrically.spec.ts new file mode 100644 index 0000000000..905c28fad8 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/deleted-folder-is-restored-asymmetrically.spec.ts @@ -0,0 +1,102 @@ +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 { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; + +test('Verify unstaging or discarding a deleted folder restores only the empty folder and its requests one by one', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + const requestSettings = { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }; + const buildRequest = (id: string, name: string, url: string, extra: Record = {}) => ({ + name, + url, + method: 'GET', + meta: { id, created: 1000, modified: 1000, isPrivate: false }, + settings: requestSettings, + ...extra, + }); + const buildFolder = (id: string, name: string, children?: unknown[]) => ({ name, meta: { id, created: 1000, modified: 1000 }, children }); + const buildCollectionFile = (workspace: { id: string; name: string; environmentId: string }, collection: unknown[]) => + 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, + ); + + 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 folder = { id: `fld_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }; + const children = [ + { 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 fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const seedFiles: Record = { + [fileName]: buildCollectionFile(workspace, [ + buildFolder( + folder.id, + folder.name, + children.map((child, index) => buildRequest(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.create(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), buildCollectionFile(workspace, [])); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + const count = (text: string) => ({ + folders: text.split(folder.id).length - 1, + requests: children.map(child => text.split(child.id).length - 1), + }); + + await gitSyncFlow.stageEntity(fileName, folder.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedAfterStage = await gitSyncPage.getEntityCardNames(); + const stagedStatuses = [ + await gitSyncPage.getEntityCardText(folder.name), + await gitSyncPage.getEntityCardText(children[0].name), + await gitSyncPage.getEntityCardText(children[1].name), + ].map(text => text.includes('Removed')); + await gitSyncFlow.unstageEntity(fileName, folder.name); + const stagedAfterUnstageFolder = await gitSyncPage.getEntityCardNames(); + const indexAfterUnstageFolder = count(execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' })); + await gitSyncFlow.discardEntity(fileName, folder.name); + const fileAfterDiscardFolder = count(fs.readFileSync(path.join(cloneDir, fileName), 'utf8')); + for (const child of children) { + await gitSyncFlow.unstageEntity(fileName, child.name); + } + const indexAfterUnstageChildren = count(execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' })); + for (const child of children) { + await gitSyncFlow.discardEntity(fileName, child.name); + } + const restoredFile = fs.readFileSync(path.join(cloneDir, fileName), 'utf8'); + + expect(stagedAfterStage.sort()).toEqual([folder.name, ...children.map(child => child.name)].sort()); + expect(stagedStatuses).toEqual([true, true, true]); + expect(stagedAfterUnstageFolder.sort()).toEqual(children.map(child => child.name).sort()); + expect(indexAfterUnstageFolder).toEqual({ folders: 1, requests: [0, 0] }); + expect(fileAfterDiscardFolder).toEqual({ folders: 1, requests: [0, 0] }); + expect(indexAfterUnstageChildren).toEqual({ folders: 1, requests: [1, 1] }); + expect(count(restoredFile)).toEqual({ folders: 1, requests: [1, 1] }); + expect(restoredFile.indexOf(folder.id)).toBeLessThan(restoredFile.indexOf(children[0].id)); + expect(restoredFile).toContain('children:'); +}); 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..6d727b8be0 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/discard-environment-variable-change.spec.ts @@ -0,0 +1,60 @@ +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, 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 { environmentFlow, gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { environmentPage, gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), faker.string.alpha(10).toLowerCase()); + 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.create(project, { + name: baseName, + type: ENVIRONMENT_TYPE, + kvPairData: originalVariables, + }); + await environmentFlow.create(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 gitSyncPage.getEntityCardNames(); + await gitSyncFlow.discardEntity(fileName, baseName); + const cardsAfter = await gitSyncPage.getEntityCardNames(); + await gitSyncPage.closeCommitDialog(); + await environmentPage.selectEnvironment(baseName); + const baseJson = await environmentPage.getRawJson(); + await environmentPage.selectEnvironment(subName); + const subJson = await environmentPage.getRawJson(); + + expect(cardsBefore.sort()).toEqual([baseName, subName].sort()); + 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/discard-last-entity-of-new-file.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/discard-last-entity-of-new-file.spec.ts new file mode 100644 index 0000000000..712317c028 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/discard-last-entity-of-new-file.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 discarding every request of a new file removes the file from Unstaged without leaving an empty shell', async ({ user }) => { + test.fail(true, "INS-4076"); + + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 2; 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 expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + for (const request of requests) { + await gitSyncFlow.discardEntity(fileName, request.name); + } + + await expect.poll(async () => gitSyncPage.getUnstagedChanges(), { timeout: DEFAULT_TIMEOUT }).not.toContain(fileName); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/discard-new-folder-warns-contents.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/discard-new-folder-warns-contents.spec.ts new file mode 100644 index 0000000000..991619c945 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/discard-new-folder-warns-contents.spec.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 discarding a new folder with contents warns that its contents go too and removes them', async ({ user }) => { + const { folderFlow, gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const folderWithContents = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + const nestedRequests: HttpRequest[] = []; + for (let i = 0; i < 2; i++) { + nestedRequests.push( + await httpRequestFlow.create( + folderWithContents, + new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get }), + ), + ); + } + 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.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.startEntityDiscard(folderWithContents.name); + const contentsConfirmLabel = await gitSyncPage.getEntityDiscardConfirmLabel(folderWithContents.name); + await gitSyncPage.confirmEntityDiscard(folderWithContents.name); + await gitSyncPage.startEntityDiscard(emptyFolder.name); + const emptyConfirmLabel = await gitSyncPage.getEntityDiscardConfirmLabel(emptyFolder.name); + await gitSyncPage.confirmEntityDiscard(emptyFolder.name); + await gitSyncPage.closeCommitDialog(); + + expect(contentsConfirmLabel).toBe('Confirm (discards its contents too)'); + expect(emptyConfirmLabel).toBe('Confirm'); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(false); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/discard-single-request.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/discard-single-request.spec.ts new file mode 100644 index 0000000000..c59e4f6114 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/discard-single-request.spec.ts @@ -0,0 +1,60 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 discarding one request from the Visual (Beta) view needs a confirmation click, can be ignored, and keeps the other edits', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 2; 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 editedUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`; + await httpRequestFlow.get(requests[0]); + await httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alphanumeric(10)}`); + await httpRequestFlow.get(requests[1]); + await httpRequestPage.setUrl(editedUrl); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.startEntityDiscard(requests[1].name); + await gitSyncPage.waitForEntityDiscardPromptToLapse(requests[1].name); + const ignoredCardVisible = await gitSyncPage.isEntityCardVisible(requests[1].name); + const ignoredActions = await gitSyncPage.getEntityCardActions(requests[1].name); + const actionsBefore = await gitSyncPage.getEntityCardActions(requests[0].name); + await gitSyncPage.startEntityDiscard(requests[0].name); + const confirmLabel = await gitSyncPage.getEntityDiscardConfirmLabel(requests[0].name); + await gitSyncPage.confirmEntityDiscard(requests[0].name); + const remainingCards = await gitSyncPage.getEntityCardNames(); + await gitSyncPage.closeCommitDialog(); + const discardedRequest = await httpRequestFlow.get(requests[0]); + const keptRequest = await httpRequestFlow.get(requests[1]); + + expect(ignoredCardVisible).toBe(true); + expect(ignoredActions).toEqual(['Discard', 'Stage']); + expect(actionsBefore).toEqual(['Discard', 'Stage']); + expect(confirmLabel).toBe('Confirm'); + expect(remainingCards).toEqual([requests[1].name]); + expect(discardedRequest?.url).toBe(requests[0].url); + expect(keptRequest?.url).toBe(editedUrl); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/file-level-identity-has-no-actions.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/file-level-identity-has-no-actions.spec.ts new file mode 100644 index 0000000000..fedae1b136 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/file-level-identity-has-no-actions.spec.ts @@ -0,0 +1,59 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 the Workspace card of an added or deleted file has no actions and a deleted file offers no per-entity Discard', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const deletedCollection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const deletedFileName = `${baseName}.yaml`; + const deletedRequests: HttpRequest[] = []; + for (let i = 0; i < 1; i++) { + deletedRequests.push( + await httpRequestFlow.create( + deletedCollection, + 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 addedBaseName = faker.string.alpha(10).toLowerCase(); + const addedCollection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), addedBaseName); + const createdAddedRequestRequests: HttpRequest[] = []; + for (let i = 0; i < 1; i++) { + createdAddedRequestRequests.push( + await httpRequestFlow.create( + addedCollection, + new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get }), + ), + ); + } + const [addedRequest] = createdAddedRequestRequests; + await workspaceFlow.delete(deletedCollection); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(2); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${addedBaseName}.yaml`); + const addedWorkspaceActions = await gitSyncPage.getEntityCardActions(addedCollection.name); + const addedRequestActions = await gitSyncPage.getEntityCardActions(addedRequest.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, deletedFileName); + const deletedWorkspaceActions = await gitSyncPage.getEntityCardActions(deletedCollection.name); + const deletedRequestActions = await gitSyncPage.getEntityCardActions(deletedRequests[0].name); + + expect(addedWorkspaceActions).toEqual([]); + expect(addedRequestActions).toEqual(['Discard', 'Stage']); + expect(deletedWorkspaceActions).toEqual([]); + expect(deletedRequestActions).toEqual(['Stage']); +}); 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..a71272933b --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/file-level-settings-appear.spec.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, 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 } = 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.create(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 gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, mockFile); + const mockCards = await gitSyncPage.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 gitSyncPage.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/file-stage-during-entity-action.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/file-stage-during-entity-action.spec.ts new file mode 100644 index 0000000000..67873c2edd --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/file-stage-during-entity-action.spec.ts @@ -0,0 +1,47 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 clicking Stage this file while an entity action is pending does not silently overwrite either action', async ({ user }) => { + test.fail(true, "INS-4079"); + + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 3; 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)); + + for (const request of requests) { + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alphanumeric(10)}`); + } + + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.stageEntityThenFileInQuickSuccession(requests[0].name); + + await expect.poll(async () => gitSyncPage.getUnstagedChanges(), { timeout: DEFAULT_TIMEOUT }).toEqual([]); + expect(await gitSyncPage.getStagedChanges()).toEqual([fileName]); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-leave-nested-requests.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-leave-nested-requests.spec.ts new file mode 100644 index 0000000000..c3a9c95bf6 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/folder-actions-leave-nested-requests.spec.ts @@ -0,0 +1,54 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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', async ({ user }) => { + const { folderFlow, gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + const folder = await folderFlow.create(collection, new Folder(faker.string.alpha(10))); + const createdRequestRequests: HttpRequest[] = []; + for (let i = 0; i < 1; i++) { + createdRequestRequests.push( + await httpRequestFlow.create( + folder, + new HttpRequest({ name: faker.string.alpha(10), url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, method: HttpMethod.Get }), + ), + ); + } + const [request] = createdRequestRequests; + 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); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(fileName, folder.name); + const unstagedCardsAfterStage = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedCards = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.unstageEntity(fileName, folder.name); + await gitSyncFlow.discardEntity(fileName, folder.name); + const unstagedCardsAfterDiscard = await gitSyncPage.getEntityCardNames(); + await gitSyncPage.closeCommitDialog(); + const requestAfterDiscard = await httpRequestFlow.get(request); + + expect(unstagedCardsAfterStage).toEqual([request.name]); + expect(stagedCards).toEqual([folder.name]); + expect(unstagedCardsAfterDiscard).toEqual([request.name]); + expect(requestAfterDiscard?.url).toBe(editedUrl); +}); 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..57b89d2eff --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/helpers.ts @@ -0,0 +1,256 @@ + +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'; + +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 build = (changed: boolean) => ({ + collection: JSON.stringify( + { + type: 'collection.insomnia.rest/5.0', + schema_version: '5.1', + name: workspaceNames.collection, + meta: { id: `wrk_${ids.workspace}`, ...FILE_TIMES }, + collection: [ + { + name: names.folder, + meta: { id: ids.folder, ...FILE_TIMES }, + headers: header(changed), + children: [ + { + name: names.request, + url: 'https://example.com/a', + method: 'GET', + meta: { id: ids.request, ...FILE_TIMES }, + headers: header(changed), + }, + ], + }, + { + name: names.webSocket, + url: 'ws://localhost:4040/', + meta: { id: ids.webSocket, ...FILE_TIMES }, + headers: header(changed), + }, + { + name: names.socketIo, + url: 'http://localhost:4050/', + meta: { id: ids.socketIo, ...FILE_TIMES }, + headers: header(changed), + }, + { + name: names.grpc, + url: 'localhost:50051', + meta: { id: ids.grpc, ...FILE_TIMES }, + metadata: header(changed), + }, + ], + cookieJar: { + name: 'Default Jar', + meta: { id: ids.cookieJar, ...FILE_TIMES }, + cookies: changed ? [{ id: 'cookie_1', key: 'session', value: 'abc', domain: 'example.com', path: '/' }] : undefined, + }, + environments: { + name: 'Base Environment', + meta: { id: ids.environment, ...FILE_TIMES }, + data: changed ? { added: '1' } : {}, + }, + }, + null, + 2, + ), + 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 }; +} + 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..44ef69ed5d --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/long-values-show-compact-diff.spec.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, 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 } = 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.create(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 gitSyncPage.getEntityCardCompactDiff(longRequest, 'Body'); + const script = await gitSyncPage.getEntityCardCompactDiff(longRequest, 'Scripts'); + const spec = await gitSyncPage.getEntityCardCompactDiff(workspace, undefined, 'API Spec'); + const shortValueIsCompact = await gitSyncPage.hasEntityCardCompactDiff(shortRequest); + const shortValueText = await gitSyncPage.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/mcp-card-shows-its-connection.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/mcp-card-shows-its-connection.spec.ts new file mode 100644 index 0000000000..80a6241aa0 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/mcp-card-shows-its-connection.spec.ts @@ -0,0 +1,23 @@ +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { MCP_SERVER } from '../../../misc/constants'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, test } from '../../../misc/git-fixtures'; +import { McpClient } from '../../../models/mcp-client'; +import { Project } from '../../../models/project'; + +test('Verify a new MCP card shows its MCP badge, transport and URL', async ({ user }) => { + const { gitSyncFlow, mcpClientFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const client = await mcpClientFlow.create(project, new McpClient(faker.string.alphanumeric(10), MCP_SERVER)); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${client.name.toLowerCase()}.yaml`); + const header = await gitSyncPage.getEntityCardHeader('MCP Request'); + + expect(header).toEqual({ badge: 'MCP', name: 'MCP Request', url: `HTTP ${MCP_SERVER}`, onOneLine: true }); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/mock-route-card-shows-status-and-body.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/mock-route-card-shows-status-and-body.spec.ts new file mode 100644 index 0000000000..bd487a9d1c --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/mock-route-card-shows-status-and-body.spec.ts @@ -0,0 +1,39 @@ +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { HttpMethod } from '../../../enums/http-method'; +import { MockBodyType } from '../../../enums/mock-body-type'; +import { ProjectType } from '../../../enums/project-types'; +import { MOCK_BIN_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, test } from '../../../misc/git-fixtures'; +import { MockServer } from '../../../models/mock-server'; +import { Project } from '../../../models/project'; + +test('Verify a new mock route card shows its method, path, status and Body tab, and the Mock Server card shows its URL', async ({ user }) => { + const { gitSyncFlow, mockServerFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const server = await mockServerFlow.create(project, new MockServer(faker.string.alphanumeric(10), MOCK_BIN_SERVER)); + const route = await mockServerFlow.createRoute(server, { + path: `/${faker.string.alpha(8)}`, + method: HttpMethod.Post, + statusCode: 201, + statusText: 'Created', + bodyType: MockBodyType.Json, + body: '{"ok":true}', + }); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${server.name.toLowerCase()}.yaml`); + const routeText = await gitSyncPage.getEntityCardText(route.path); + const routeTabs = await gitSyncPage.getEntityCardTabs(route.path); + const serverText = await gitSyncPage.getEntityCardText(MOCK_BIN_SERVER, 'Mock Server'); + + expect(routeText).toContain(`POST\n${route.path}\n201 Created`); + expect(routeTabs).toEqual(['Body']); + expect(routeText).toContain('application/json'); + expect(routeText).toContain('{"ok":true}'); + expect(serverText).toContain(MOCK_BIN_SERVER); +}); 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..31f8a038e0 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/modified-request-and-folder-cards-show-changes.spec.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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, and a folder card shows its script', async ({ user }) => { + const { folderFlow, gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(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); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${baseName}.yaml`); + const requestText = await gitSyncPage.getEntityCardText(request.name); + const requestTabs = await gitSyncPage.getEntityCardTabs(request.name); + const folderText = await gitSyncPage.getEntityCardText(folder.name); + const folderTabs = await gitSyncPage.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']); + expect(folderTabs).toEqual(['Scripts']); + expect(folderText).toContain("console.log('folder');"); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/moved-request-is-not-duplicated.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/moved-request-is-not-duplicated.spec.ts new file mode 100644 index 0000000000..119b53e069 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/moved-request-is-not-duplicated.spec.ts @@ -0,0 +1,40 @@ +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 { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; +import { planMovedRequest } from './helpers'; + +test('Verify staging or discarding a request that moved into a folder leaves exactly one copy of it', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + const { requestId, requestName, build } = planMovedRequest(); + 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.create(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.stageEntity(fileName, requestName); + const copiesInIndexAfterStage = execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' }).split(requestId).length - 1; + await gitSyncFlow.unstageEntity(fileName, requestName); + const copiesInIndexAfterUnstage = execSync(`git show :${fileName}`, { cwd: cloneDir, encoding: 'utf8' }).split(requestId).length - 1; + await gitSyncFlow.discardEntity(fileName, requestName); + const copiesInFileAfterDiscard = fs.readFileSync(path.join(cloneDir, fileName), 'utf8').split(requestId).length - 1; + + expect(copiesInIndexAfterStage).toBe(1); + expect(copiesInIndexAfterUnstage).toBe(1); + expect(copiesInFileAfterDiscard).toBe(1); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/partial-staging-new-file-keeps-settings.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/partial-staging-new-file-keeps-settings.spec.ts new file mode 100644 index 0000000000..a461dc9232 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/partial-staging-new-file-keeps-settings.spec.ts @@ -0,0 +1,40 @@ +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 { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; +import { planEntityTypeFiles } from './helpers'; + +test('Verify 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 } = user.pageManager; + + const { names, build } = planEntityTypeFiles(); + const existingFile = `${faker.string.alpha(8).toLowerCase()}.yaml`; + const newFile = `${faker.string.alpha(9).toLowerCase()}.yaml`; + const seedFiles: Record = { [existingFile]: build(false).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.create(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, newFile), build(true).mock); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(newFile, 'https://mock.example.com'); + const stagedWithServerOnly = execSync(`git show :${newFile}`, { cwd: cloneDir, encoding: 'utf8' }); + await gitSyncFlow.stageEntity(newFile, names.mockRoute); + const stagedWithServerAndRoute = execSync(`git show :${newFile}`, { cwd: cloneDir, encoding: 'utf8' }); + + expect(stagedWithServerOnly).toContain('https://mock.example.com'); + expect(stagedWithServerOnly).not.toContain(names.mockRoute); + expect(stagedWithServerAndRoute).toContain('https://mock.example.com'); + expect(stagedWithServerAndRoute).toContain(names.mockRoute); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/request-card-shows-every-section.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/request-card-shows-every-section.spec.ts new file mode 100644 index 0000000000..a1d441b3e7 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/request-card-shows-every-section.spec.ts @@ -0,0 +1,64 @@ +import { faker } from '@faker-js/faker'; + +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, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, expect, 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 a new request card shows each populated section as its own tab', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + await gitSyncFlow.commit(faker.string.alphanumeric(10)); + const request = await httpRequestFlow.create( + collection, + new HttpRequest({ + name: faker.string.alpha(10), + url: `${HTTP_SERVER}/${faker.string.alphanumeric(10)}`, + 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');", + }), + ); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${baseName}.yaml`); + const tabs = await gitSyncPage.getEntityCardTabs(request.name); + const header = await gitSyncPage.getEntityCardHeader(request.name); + + const sectionTexts: Record = {}; + for (const tab of ['Params', 'Body', 'Auth', 'Headers', 'Scripts']) { + await gitSyncPage.selectEntityCardTab(request.name, tab); + sectionTexts[tab] = await gitSyncPage.getEntityCardText(request.name); + } + + expect(header).toMatchObject({ badge: 'POST', name: request.name, url: request.url, onOneLine: true }); + expect(tabs).toEqual(['Params 1', 'Body', 'Auth', 'Headers 2', 'Scripts']); + expect(sectionTexts.Params).toContain('page'); + expect(sectionTexts.Body).toContain('application/json'); + expect(sectionTexts.Body).toContain('{"key":"value"}'); + expect(sectionTexts.Auth).toContain('client_credentials'); + expect(sectionTexts.Auth).toContain(`${OAUTH2_SERVER}/token`); + expect(sectionTexts.Headers).toContain('X-Trace'); + expect(sectionTexts.Scripts).toContain("console.log('pre');"); + expect(sectionTexts.Scripts).toContain("console.log('after');"); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/request-moved-into-new-folder-returns-to-place.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/request-moved-into-new-folder-returns-to-place.spec.ts new file mode 100644 index 0000000000..eec7982266 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/request-moved-into-new-folder-returns-to-place.spec.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; + +test('Verify discarding a new folder a request moved into removes the request until it is restored to its original place', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + const requestSettings = { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }; + const buildRequest = (id: string, name: string, url: string, extra: Record = {}) => ({ + name, + url, + method: 'GET', + meta: { id, created: 1000, modified: 1000, isPrivate: false }, + settings: requestSettings, + ...extra, + }); + const buildFolder = (id: string, name: string, children?: unknown[]) => ({ name, meta: { id, created: 1000, modified: 1000 }, children }); + const buildCollectionFile = (workspace: { id: string; name: string; environmentId: string }, collection: unknown[]) => + 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, + ); + + 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 folder = { id: `fld_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }; + const request = { id: `req_${faker.string.alphanumeric({ length: 16, casing: 'lower' })}`, name: faker.string.alpha(10) }; + const fileName = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const requestNode = () => buildRequest(request.id, request.name, 'https://example.com/moved'); + const original = buildCollectionFile(workspace, [requestNode()]); + const seedFiles: Record = { [fileName]: original }; + 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.create(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), buildCollectionFile(workspace, [buildFolder(folder.id, folder.name, [requestNode()])])); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + const cardsBefore = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.stageEntity(fileName, folder.name); + await gitSyncFlow.unstageEntity(fileName, folder.name); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, fileName); + await gitSyncPage.startEntityDiscard(folder.name); + const confirmLabel = await gitSyncPage.getEntityDiscardConfirmLabel(folder.name); + await gitSyncPage.confirmEntityDiscard(folder.name); + const cardsAfterFolderDiscard = await gitSyncPage.getEntityCardNames(); + const requestCardText = await gitSyncPage.getEntityCardText(request.name); + await gitSyncFlow.discardEntity(fileName, request.name); + const restored = fs.readFileSync(path.join(cloneDir, fileName), 'utf8'); + + expect(cardsBefore).toEqual([folder.name]); + expect(confirmLabel).toBe('Confirm (discards its contents too)'); + expect(cardsAfterFolderDiscard).toEqual([request.name]); + expect(requestCardText).toContain('Removed'); + expect(restored.split(request.id).length - 1).toBe(1); + expect(restored.split(folder.id).length - 1).toBe(0); +}); 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..3e242cc6e4 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/same-named-headers-are-numbered.spec.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; + +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 } = user.pageManager; + + const requestSettings = { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }; + const buildRequest = (id: string, name: string, url: string, extra: Record = {}) => ({ + name, + url, + method: 'GET', + meta: { id, created: 1000, modified: 1000, isPrivate: false }, + settings: requestSettings, + ...extra, + }); + const buildCollectionFile = (workspace: { id: string; name: string; environmentId: string }, collection: unknown[]) => + 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, + ); + + 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]: buildCollectionFile(workspace, [ + buildRequest(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.create(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), buildCollectionFile(workspace, [ + buildRequest(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 gitSyncPage.getEntityCardTabs(request.name); + const cardText = await gitSyncPage.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/socket-io-card-shows-its-sections.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/socket-io-card-shows-its-sections.spec.ts new file mode 100644 index 0000000000..d04354d983 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/socket-io-card-shows-its-sections.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, test } from '../../../misc/git-fixtures'; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { SocketIORequest } from '../../../models/socket-io-request'; + +test('Verify a new Socket.IO request card shows its IO badge, URL and Headers tab', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, socketIoRequestFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const request = await socketIoRequestFlow.create( + collection, + new SocketIORequest({ + name: faker.string.alpha(10), + url: HTTP_SERVER, + headers: [{ name: 'X-Token', value: faker.string.alphanumeric(8) }], + }), + ); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${baseName}.yaml`); + const header = await gitSyncPage.getEntityCardHeader(request.name); + const tabs = await gitSyncPage.getEntityCardTabs(request.name); + const cardText = await gitSyncPage.getEntityCardText(request.name); + + expect(header).toEqual({ badge: 'IO', name: request.name, url: request.url, onOneLine: true }); + expect(tabs).toEqual(['Headers 1']); + expect(cardText).toContain('X-Token'); +}); 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..f7720ac622 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/stage-in-file-with-non-canonical-head.spec.ts @@ -0,0 +1,43 @@ +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 { HttpMethod } from '../../../enums/http-method'; +import { ProjectType } from '../../../enums/project-types'; +import { HTTP_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, expect, 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.create(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-single-request.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/stage-single-request.spec.ts new file mode 100644 index 0000000000..23f3f25f2d --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/stage-single-request.spec.ts @@ -0,0 +1,55 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 one request from the Visual (Beta) view leaves the others unstaged and each side offers the right card actions', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 3; 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)); + + for (const request of requests) { + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alphanumeric(10)}`); + } + + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(fileName, requests[0].name); + const unstagedCards = await gitSyncPage.getEntityCardNames(); + const unstagedActions = await gitSyncPage.getEntityCardActions(requests[1].name); + const unstagedFiles = await gitSyncPage.getUnstagedChanges(); + const stagedFiles = await gitSyncPage.getStagedChanges(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedCards = await gitSyncPage.getEntityCardNames(); + const stagedActions = await gitSyncPage.getEntityCardActions(requests[0].name); + + expect(unstagedCards.sort()).toEqual([requests[1].name, requests[2].name].sort()); + expect(stagedFiles).toEqual([fileName]); + expect(unstagedFiles).toEqual([fileName]); + expect(stagedCards).toEqual([requests[0].name]); + expect(unstagedActions).toEqual(['Discard', 'Stage']); + expect(stagedActions).toEqual(['Unstage']); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/staged-and-unstaged-lists-stay-consistent.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/staged-and-unstaged-lists-stay-consistent.spec.ts new file mode 100644 index 0000000000..82ed46b6ed --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/staged-and-unstaged-lists-stay-consistent.spec.ts @@ -0,0 +1,65 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 entities keeps the Staged and Unstaged lists consistent', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 3; 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 [entityA, entityB, entityC] = requests; + for (const request of requests) { + await httpRequestFlow.get(request); + await httpRequestPage.setUrl(`${HTTP_SERVER}/${faker.string.alphanumeric(10)}`); + } + + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.stageEntity(fileName, entityA.name); + await gitSyncFlow.stageEntity(fileName, entityB.name); + const unstagedAfterStagingTwo = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Staged, fileName); + const stagedAfterStagingTwo = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.unstageEntity(fileName, entityA.name); + const stagedAfterUnstaging = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.discardEntity(fileName, entityC.name); + const unstagedAfterDiscard = await gitSyncPage.getEntityCardNames(); + const filesBeforeLastStage = { + staged: await gitSyncPage.getStagedChanges(), + unstaged: await gitSyncPage.getUnstagedChanges(), + }; + await gitSyncFlow.stageEntity(fileName, entityA.name); + const filesAfterLastStage = { + staged: await gitSyncPage.getStagedChanges(), + unstaged: await gitSyncPage.getUnstagedChanges(), + }; + + expect(unstagedAfterStagingTwo).toEqual([entityC.name]); + expect(stagedAfterStagingTwo.sort()).toEqual([entityA.name, entityB.name].sort()); + expect(stagedAfterUnstaging).toEqual([entityB.name]); + expect(unstagedAfterDiscard).toEqual([entityA.name]); + expect(filesBeforeLastStage).toEqual({ staged: [fileName], unstaged: [fileName] }); + expect(filesAfterLastStage).toEqual({ staged: [fileName], unstaged: [] }); +}); 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..ac87ecb79b --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/unknown-fields-are-preserved.spec.ts @@ -0,0 +1,81 @@ +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 { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, pushServerFile,test } from '../../../misc/git-fixtures'; +import { Project } from '../../../models/project'; + +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 } = user.pageManager; + + const requestSettings = { renderRequestBody: true, encodeUrl: true, followRedirects: 'global', cookies: { send: true, store: true }, rebuildPath: true }; + const buildRequest = (id: string, name: string, url: string, extra: Record = {}) => ({ + name, + url, + method: 'GET', + meta: { id, created: 1000, modified: 1000, isPrivate: false }, + settings: requestSettings, + ...extra, + }); + const buildCollectionFile = (workspace: { id: string; name: string; environmentId: string }, collection: unknown[]) => + 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, + ); + + 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]: buildCollectionFile(workspace, [ + buildRequest(first.id, first.name, 'https://example.com/first'), + buildRequest(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.create(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), buildCollectionFile(workspace, [ + buildRequest(first.id, first.name, 'https://example.com/first-edited'), + buildRequest(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 gitSyncPage.getEntityCardNames(); + const secondTabsAfterDiscard = await gitSyncPage.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..54fed623b1 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/vault-secrets-stay-masked.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 { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { DEFAULT_TIMEOUT, expect, 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 } = 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.create(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 gitSyncPage.getEntityCardTabs('Base Environment'); + const variablesText = await gitSyncPage.getEntityCardText('Base Environment'); + await gitSyncPage.selectEntityCardTab('Base Environment', 'Secrets'); + const secretsText = await gitSyncPage.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-tab-and-default-view.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/visual-tab-and-default-view.spec.ts new file mode 100644 index 0000000000..e7c7c898cc --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/visual-tab-and-default-view.spec.ts @@ -0,0 +1,57 @@ +import { faker } from '@faker-js/faker'; + +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, expect, 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 the diff pane defaults to Text, offers Visual (Beta), keeps the view across files and shows a request card header on one line', async ({ user }) => { + const { gitSyncFlow, httpRequestFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage, httpRequestPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const fileName = `${baseName}.yaml`; + + const requests: HttpRequest[] = []; + for (let i = 0; i < 1; 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)); + await httpRequestFlow.get(requests[0]); + const longUrl = `${HTTP_SERVER}/${faker.string.alphanumeric(10)}/${faker.string.alphanumeric(120)}`; + await httpRequestPage.setUrl(longUrl); + const otherBaseName = faker.string.alpha(10).toLowerCase(); + await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), otherBaseName); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncPage.openCommitDialog(); + await gitSyncPage.openChangeDiff(GitChangeList.Unstaged, fileName); + const defaultView = await gitSyncPage.getDiffView(); + const hasTextOption = await gitSyncPage.hasDiffViewOption(GitDiffView.Text); + const hasVisualOption = await gitSyncPage.hasDiffViewOption(GitDiffView.Visual); + await gitSyncPage.switchDiffView(GitDiffView.Visual); + const header = await gitSyncPage.getEntityCardHeader(requests[0].name); + const tabs = await gitSyncPage.getEntityCardTabs(requests[0].name); + await gitSyncPage.openChangeDiff(GitChangeList.Unstaged, `${otherBaseName}.yaml`); + const viewAfterSwitchingFile = await gitSyncPage.getDiffView(); + + expect(defaultView).toBe(GitDiffView.Text); + expect(hasTextOption).toBe(true); + expect(hasVisualOption).toBe(true); + expect(viewAfterSwitchingFile).toBe(GitDiffView.Visual); + expect(header).toEqual({ badge: 'GET', name: requests[0].name, url: longUrl, onOneLine: true }); + expect(tabs).toEqual([]); +}); 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..9cff06d420 --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/visual-view-shows-notice-instead-of-cards.spec.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { GitDiffView } from '../../../enums/git-diff-view'; +import { ProjectType } from '../../../enums/project-types'; +import { buildCollectionFile, DEFAULT_TIMEOUT, expect, 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 and for non-Insomnia files', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + const conflictFile = `${faker.string.alpha(10).toLowerCase()}.yaml`; + const spectralFile = '.spectral.yaml'; + const openApiFile = `${faker.string.alpha(11).toLowerCase()}.yaml`; + const original = buildCollectionFile(faker.string.alpha(10)); + const seedFiles: Record = { + [conflictFile]: original, + [spectralFile]: 'extends: spectral:oas\n', + [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.create(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.appendFileSync(path.join(cloneDir, openApiFile), ` version: ${faker.string.numeric(3)}\n`); + await expect + .poll(async () => (await gitSyncFlow.getChanges()).unstaged.length, { timeout: DEFAULT_TIMEOUT }) + .toBe(3); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, conflictFile); + const conflictNotice = await gitSyncPage.getVisualDiffNotice(); + const conflictCards = await gitSyncPage.getEntityCardNames(); + await gitSyncPage.openTextViewFromNotice(); + const viewAfterConflictLink = await gitSyncPage.getDiffView(); + const conflictContent = fs.readFileSync(path.join(cloneDir, conflictFile), 'utf8'); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, spectralFile); + const spectralNotice = await gitSyncPage.getVisualDiffNotice(); + const spectralCards = await gitSyncPage.getEntityCardNames(); + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, openApiFile); + const openApiNotice = await gitSyncPage.getVisualDiffNotice(); + await gitSyncPage.openTextViewFromNotice(); + const viewAfterOpenApiLink = await gitSyncPage.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(spectralCards).toEqual([]); + expect([viewAfterConflictLink, viewAfterOpenApiLink]).toEqual([GitDiffView.Text, GitDiffView.Text]); +}); diff --git a/packages/insomnia-test/tests/git-sync/visual-diff/websocket-card-shows-its-sections.spec.ts b/packages/insomnia-test/tests/git-sync/visual-diff/websocket-card-shows-its-sections.spec.ts new file mode 100644 index 0000000000..c0127ed15f --- /dev/null +++ b/packages/insomnia-test/tests/git-sync/visual-diff/websocket-card-shows-its-sections.spec.ts @@ -0,0 +1,39 @@ +import { faker } from '@faker-js/faker'; + +import { GitChangeList } from '../../../enums/git-change-list'; +import { ProjectType } from '../../../enums/project-types'; +import { WS_SERVER } from '../../../misc/fixtures'; +import { DEFAULT_TIMEOUT, expect, GIT_CREDENTIAL, test } from '../../../misc/git-fixtures'; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; +import { WebSocketRequest } from '../../../models/websocket-request'; + +test('Verify a new WebSocket request card shows its WS badge, URL and Params and Headers tabs', async ({ user }) => { + const { gitSyncFlow, preferencesFlow, webSocketRequestFlow, workspaceFlow } = user.flowManager; + const { gitSyncPage } = user.pageManager; + + await preferencesFlow.addGitCredential(GIT_CREDENTIAL); + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Git), GIT_CREDENTIAL.name); + const baseName = faker.string.alpha(10).toLowerCase(); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10)), baseName); + const request = await webSocketRequestFlow.create( + collection, + 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) }], + }), + ); + await expect.poll(async () => gitSyncPage.hasUncommittedChanges(), { timeout: DEFAULT_TIMEOUT }).toBe(true); + + await gitSyncFlow.openVisualDiff(GitChangeList.Unstaged, `${baseName}.yaml`); + const header = await gitSyncPage.getEntityCardHeader(request.name); + const tabs = await gitSyncPage.getEntityCardTabs(request.name); + await gitSyncPage.selectEntityCardTab(request.name, 'Headers'); + const headersText = await gitSyncPage.getEntityCardText(request.name); + + expect(header).toEqual({ badge: 'WS', name: request.name, url: request.url, onOneLine: true }); + expect(tabs).toEqual(['Params 1', 'Headers 1']); + expect(headersText).toContain('X-Token'); +});