diff --git a/.claude/skills/write-insomnia-test/reference.md b/.claude/skills/write-insomnia-test/reference.md index 9f690e5afe..4d57bc1b6e 100644 --- a/.claude/skills/write-insomnia-test/reference.md +++ b/.claude/skills/write-insomnia-test/reference.md @@ -23,7 +23,7 @@ Every test injects only the `user` fixture (`async ({ user }) => {...}`) — nev | Certificates (CA + Client, `tests/http-request/`, `tests/grpc-request/`) | `certificatesFlow` | `flows/certificates.flow.ts` | `pages/certificates.page.ts` | `models/certificate.ts` | | Folder (a Collection sub-container — creation + its own Auth/Headers/Scripts/Environment/Docs tab) | `folderFlow` | `flows/folder.flow.ts` | `pages/folder.page.ts` (extends the shared `pages/auth-tab.page.ts`, same as `RequestPage`) | `models/folder.ts` | | Import | `importFlow` | `flows/import.flow.ts` | `pages/import.page.ts` | `models/curl-command.ts` (other sources import raw string/file path) | -| Export | `exportFlow` | `flows/export.flow.ts` | `pages/export.page.ts` | none — takes `Collection`/`Project` + `ExportFormat` (`enums/export-format.ts`) | +| Export | `exportFlow` | `flows/export.flow.ts` | `pages/export.page.ts` | none — takes `Collection`/`Project` + `ExportFormat` (`enums/export-format.ts`), or `ApiSpecExportFormat` (`enums/api-spec-export-format.ts`) for the OpenAPI-spec-only export | | Response (read-only, returned by `send`/`connect`) | — (via `user.pageManager.responsePage`) | — | `pages/response.page.ts` | `models/response.ts` | | Preferences (app-wide Settings, not a domain entity) | `preferencesFlow` | `flows/preferences.flow.ts` | `pages/preferences.page.ts` | `models/settings.ts` (simple fields only; complex ones out of scope — `filterResponsesByEnv`/`timeout`/`proxyEnabled`/`httpProxy`/`httpsProxy`/`noProxy`/`sidebarFocusForCollections` (defaults `false` in the `user` fixture, see below) all wired into `set()`) | | AI Settings — "url" backend (Preferences → AI Settings tab, `tests/preferences/ai-backend-settings.spec.ts`) | `preferencesFlow` (`set({ aiUrlBackend })` — configured/deactivated entirely through the real UI form, no bridge call) | `flows/preferences.flow.ts` | `pages/preferences.page.ts` | `models/settings.ts`'s `AiUrlBackendSettings` (synthetic `Settings` field, not a real `AppSettings` member) | @@ -939,7 +939,8 @@ export(item: Collection, format: ExportFormat, path: string): Promise // same dialogs but has no dedicated method/coverage here. That context menu now also has an // "Export OpenAPI Spec" entry alongside "Export" — clickContextMenu() matches by exact name so the // two don't collide, but a raw getByRole("menuitem", { name: "Export" }) without exact:true will - // match both and throw a strict-mode violation. + // match both and throw a strict-mode violation. See "Export OpenAPI Spec" below for that entry's + // own dedicated methods. export(item: Project, format: ExportFormat, path: string): Promise // via Preferences -> Data -> "Export......Project" button -> selects format -> skips // request-selection (a Project export always includes every request) -> one file per workspace @@ -958,6 +959,31 @@ All three overloads stub Electron's native dialogs first via `pageManager.export Real example: `tests/data/export/happy-path.spec.ts` — exports a Collection via its context menu twice (HAR + Insomnia v5, `export(collection, ...)`) and a Project via Preferences -> Data to a directory (Insomnia v5, `export(project, ...)`), asserting each written file's content via `waitForExportedFile()`. +### Export OpenAPI Spec (INS-3641, `ContextMenuItem.ExportOpenApiSpec`) + +Exports only a Collection's OpenAPI spec (not the rest of the collection) via the sidebar workspace dropdown's "Export OpenAPI Spec" entry, reachable from both the row's `⋮` trigger and right-click: + +```ts +// ExportFlow (flows/export.flow.ts) +exportOpenApiSpec(item: Collection, format: ApiSpecExportFormat, path: string): Promise + // resolves the node, stubs the native save dialog, opens the `⋮` dropdown + // (WorkspacePage.openWorkspaceActionsDropdown()), clicks ContextMenuItem.ExportOpenApiSpec, then + // calls ExportPage.exportOpenApiSpec(format) (below) to handle whatever shows next. +``` + +`ApiSpecExportFormat` (`enums/api-spec-export-format.ts`): `Yaml = "YAML" | Json = "JSON"` — a distinct enum from `ExportFormat` since the spec-only modal's options ("YAML"/"JSON") aren't the same as the full-collection export's ("Insomnia v5"/"HAR – HTTP Archive Format"). + +`pageManager.workspacePage`: + +- `openWorkspaceActionsDropdown(node: TreeNode): Promise` — hovers the row then clicks its `⋮` ("SideBar Workspace Actions") trigger, opening the same underlying dropdown `rightClick()` does. +- `getContextMenuItem(item: ContextMenuItem): Locator` — the same menuitem/menuitemradio locator `clickContextMenu()` clicks, exposed so a caller can assert an item's visibility without clicking it (e.g. confirming an item is offered on right-click without triggering it). + +`pageManager.exportPage.exportOpenApiSpec(format: ApiSpecExportFormat): Promise` — decorated with `@throwOnDialog()` (`misc/decorators.ts`, the same decorator `RequestPage.send()`/`GrpcRequestPage.fetchServerReflection()` use). Waits for any dialog the click could have opened, then checks specifically whether it's the "Select Specification Format" modal: if so, selects `format` and clicks "Done"; if not (a "Cannot export" alert appeared instead, for an empty/missing spec), it's a no-op that leaves the alert open, and `@throwOnDialog`'s post-check then rejects the call with the alert's trimmed text — so a caller asserts the empty-spec path with `.rejects.toThrow("does not contain an OpenAPI specification to export")` (same `.rejects.toThrow()` pattern as `tests/environment/unlinked-environment-variable.spec.ts`), then dismisses the alert via `workspaceFlow.closeDialog()` before continuing. + +`workspaceFlow.fillSpecification(item: Collection, spec: NonNullable): Promise` — types `spec` into an already-existing collection's spec editor at any point after creation (unlike the `spec` passed to `create()`, which only authors right after creation) — e.g. filling in a collection that started out empty, per the empty-spec-then-filled test below. + +Real example: `tests/data/export/export-openapi-spec.spec.ts` — exports a Pet-Store-shaped spec as both YAML and JSON from the sidebar dropdown (asserting format conversion), and confirms the empty-spec path rejects via `.rejects.toThrow(...)` followed by a successful export once `fillSpecification()` fills it in. (`WorkspacePage.getContextMenuItem()` is available for a right-click-visibility test but no committed spec exercises it yet — re-probe before adding one.) Note: an OpenAPI spec authored with `paths: {}` (no paths at all) breaks `WorkspaceFlow.getCollectionSpec()`'s outline-section expansion (used internally by `create()` when given a `spec`) — always include at least one real path in a spec passed to `create()`/`fillSpecification()`. + ## Collection spec / lint / legacy unit tests (`workspaceFlow` / `pageManager.workspacePage`) **INS-3528 unified the former standalone "Document" workspace into the Collection workspace** — there is no `Document` model, `DocumentFlow`, or `DocumentPage` anymore; any Collection can optionally carry an OpenAPI/Swagger spec (and, with a setting on, legacy unit test suites), and everything below lives directly on `workspaceFlow`/`workspacePage`. A spec-carrying Collection's requests live in the exact same standalone project tree a plain Collection's do — there is no separate embedded request tree/tab to special-case; use the regular `RequestFlow.create(collection, ...)` pattern documented elsewhere in this file for those. diff --git a/packages/insomnia-test/enums/api-spec-export-format.ts b/packages/insomnia-test/enums/api-spec-export-format.ts new file mode 100644 index 0000000000..5d82d2334d --- /dev/null +++ b/packages/insomnia-test/enums/api-spec-export-format.ts @@ -0,0 +1,4 @@ +export enum ApiSpecExportFormat { + Yaml = "YAML", + Json = "JSON", +} diff --git a/packages/insomnia-test/enums/context-menu-items.ts b/packages/insomnia-test/enums/context-menu-items.ts index 618b685722..212eae9929 100644 --- a/packages/insomnia-test/enums/context-menu-items.ts +++ b/packages/insomnia-test/enums/context-menu-items.ts @@ -32,5 +32,6 @@ export enum ContextMenuItem { Unpin = "Unpin", OpenInNewTab = "Open in New Tab", Export = "Export", + ExportOpenApiSpec = "Export OpenAPI Spec", GenerateCode = "Generate Code", } diff --git a/packages/insomnia-test/flows/export.flow.ts b/packages/insomnia-test/flows/export.flow.ts index 97e914ffd4..f488a3050a 100644 --- a/packages/insomnia-test/flows/export.flow.ts +++ b/packages/insomnia-test/flows/export.flow.ts @@ -1,3 +1,4 @@ +import type { ApiSpecExportFormat } from "../enums/api-spec-export-format"; import { ContextMenuItem } from "../enums/context-menu-items"; import type { ExportFormat } from "../enums/export-format"; import { Collection } from "../models/collection"; @@ -80,4 +81,30 @@ export class ExportFlow extends BaseFlow { await exportPage.dismissCompletionIfShown(); if (await preferencesPage.isOpen()) await preferencesPage.close(); } + + /** + * Exports a Collection's OpenAPI spec (only the spec, not the rest of + * the collection) to a single file via the sidebar workspace + * dropdown's "Export OpenAPI Spec" item, choosing `format` in the + * resulting modal. If the collection's spec is empty/missing, the + * click surfaces a "Cannot export" alert instead of the format modal — + * `ExportPage.exportOpenApiSpec()` is decorated with `@throwOnDialog`, + * so this rejects with that alert's trimmed text in that case; dismiss + * it with `workspaceFlow.closeDialog()` before continuing. + * @param item - The Collection whose OpenAPI spec to export + * @param format - YAML or JSON + * @param path - Absolute file path the exported spec should be written to + */ + async exportOpenApiSpec( + item: Collection, + format: ApiSpecExportFormat, + path: string, + ): Promise { + const { workspacePage, exportPage } = this.pageManager; + const node = await workspacePage.resolveNode(item); + await exportPage.stubSaveFileLocation(path); + await workspacePage.openWorkspaceActionsDropdown(node); + await workspacePage.clickContextMenu(ContextMenuItem.ExportOpenApiSpec); + await exportPage.exportOpenApiSpec(format); + } } diff --git a/packages/insomnia-test/flows/workspace.flow.ts b/packages/insomnia-test/flows/workspace.flow.ts index 9fef0d3bc1..b15f4b5a1e 100644 --- a/packages/insomnia-test/flows/workspace.flow.ts +++ b/packages/insomnia-test/flows/workspace.flow.ts @@ -1,37 +1,37 @@ -import { expect } from "@playwright/test"; +import { expect } from '@playwright/test'; -import { ContextMenuItem } from "../enums/context-menu-items"; -import { ProjectType } from "../enums/project-types"; -import { TreeNodeType } from "../enums/tree-node-types"; -import { DEFAULT_TIMEOUT } from "../misc/fixtures"; -import { Collection, TestSuite, UnitTest } from "../models/collection"; -import { Environment, isEnvironmentItem } from "../models/environment"; -import type { EventStreamRequest } from "../models/event-stream-request"; -import type { GitRepoConnection } from "../models/git-repo-connection"; -import type { GraphQLRequest } from "../models/graphql-request"; -import type { GrpcRequest } from "../models/grpc-request"; -import type { HttpRequest } from "../models/http-request"; -import { McpClient } from "../models/mcp-client"; -import { Project } from "../models/project"; -import type { RunnerOptions, RunnerRunResult } from "../models/runner"; -import { Settings } from "../models/settings"; -import type { SocketIORequest } from "../models/socket-io-request"; -import type { WebSocketRequest } from "../models/websocket-request"; -import type { PageManager } from "../pages/page-manager"; -import type { TreeNode, UnitTestResultRow } from "../pages/workspace.page"; -import { BaseFlow } from "./base.flow"; -import type { FlowManager } from "./flow-manager"; +import { ContextMenuItem } from '../enums/context-menu-items'; +import { ProjectType } from '../enums/project-types'; +import { TreeNodeType } from '../enums/tree-node-types'; +import { DEFAULT_TIMEOUT } from '../misc/fixtures'; +import { Collection, TestSuite, UnitTest } from '../models/collection'; +import { Environment, isEnvironmentItem } from '../models/environment'; +import type { EventStreamRequest } from '../models/event-stream-request'; +import type { GitRepoConnection } from '../models/git-repo-connection'; +import type { GraphQLRequest } from '../models/graphql-request'; +import type { GrpcRequest } from '../models/grpc-request'; +import type { HttpRequest } from '../models/http-request'; +import { McpClient } from '../models/mcp-client'; +import { Project } from '../models/project'; +import type { RunnerOptions, RunnerRunResult } from '../models/runner'; +import { Settings } from '../models/settings'; +import type { SocketIORequest } from '../models/socket-io-request'; +import type { WebSocketRequest } from '../models/websocket-request'; +import type { PageManager } from '../pages/page-manager'; +import type { TreeNode, UnitTestResultRow } from '../pages/workspace.page'; +import { BaseFlow } from './base.flow'; +import type { FlowManager } from './flow-manager'; export type WorkspaceItem = - | (Project & { kind: "project" }) - | (Collection & { kind: "collection" }) - | (McpClient & { kind: "mcpClient" }) - | (HttpRequest & { kind: "http" }) - | (GraphQLRequest & { kind: "graphql" }) - | (GrpcRequest & { kind: "grpc" }) - | (EventStreamRequest & { kind: "eventStream" }) - | (SocketIORequest & { kind: "socketIO" }) - | (WebSocketRequest & { kind: "webSocket" }); + | (Project & { kind: 'project' }) + | (Collection & { kind: 'collection' }) + | (McpClient & { kind: 'mcpClient' }) + | (HttpRequest & { kind: 'http' }) + | (GraphQLRequest & { kind: 'graphql' }) + | (GrpcRequest & { kind: 'grpc' }) + | (EventStreamRequest & { kind: 'eventStream' }) + | (SocketIORequest & { kind: 'socketIO' }) + | (WebSocketRequest & { kind: 'webSocket' }); /** The settled outcome of `WorkspaceFlow.runAllTests()`. */ export interface UnitTestRunResult { @@ -74,11 +74,7 @@ export class WorkspaceFlow extends BaseFlow { * where each collection is backed by its own file * @returns The created Collection, with `id` populated */ - async create( - parent: Project, - item: Collection, - fileName?: string, - ): Promise; + async create(parent: Project, item: Collection, fileName?: string): Promise; /** * Creates an McpClient under `parent`. * @param parent - The Project to create `item` under @@ -145,7 +141,7 @@ export class WorkspaceFlow extends BaseFlow { await (p.folderPath !== undefined ? this.openFolder(p) : this.createProject(p)); return this.assertCreated(await this.getProject(p.name), p.name); } - if (typeof itemOrCredentialName === "string") { + if (typeof itemOrCredentialName === 'string') { return this.cloneFromRemote( parentOrItem, itemOrCredentialName, @@ -161,24 +157,15 @@ export class WorkspaceFlow extends BaseFlow { } if (isEnvironmentItem(item)) { await this.createEnvironment(parent, item.name); - return this.assertCreated( - await this.getEnvironment(item.name), - item.name, - ); + return this.assertCreated(await this.getEnvironment(item.name), item.name); } const collection = item as Collection; await this.createCollection(parent, collection.name, fileName); if (collection.spec) { await this.authorSpec(collection.name, collection.spec); - return this.assertCreated( - await this.getCollectionSpec(collection.name), - collection.name, - ); + return this.assertCreated(await this.getCollectionSpec(collection.name), collection.name); } - return this.assertCreated( - await this.getCollection(collection.name), - collection.name, - ); + return this.assertCreated(await this.getCollection(collection.name), collection.name); } /** @@ -192,10 +179,7 @@ export class WorkspaceFlow extends BaseFlow { * @param collection - The collection to create, with an optional `spec` to author * @returns The created collection, re-fetched via `getCollectionSpec()`/`getCollection()` */ - async createInEmptyState( - parent: Project, - collection: Collection, - ): Promise { + async createInEmptyState(parent: Project, collection: Collection): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); await workspace.clickNode(node); @@ -203,15 +187,9 @@ export class WorkspaceFlow extends BaseFlow { if (collection.spec) { await this.authorSpec(collection.name, collection.spec); - return this.assertCreated( - await this.getCollectionSpec(collection.name), - collection.name, - ); + return this.assertCreated(await this.getCollectionSpec(collection.name), collection.name); } - return this.assertCreated( - await this.getCollection(collection.name), - collection.name, - ); + return this.assertCreated(await this.getCollection(collection.name), collection.name); } /** @@ -289,9 +267,7 @@ export class WorkspaceFlow extends BaseFlow { const node = await this.pageManager.workspacePage.findItemNode(item); if (!node) return; await this.pageManager.workspacePage.rightClick(node); - await this.pageManager.workspacePage.clickContextMenu( - ContextMenuItem.Delete, - ); + await this.pageManager.workspacePage.clickContextMenu(ContextMenuItem.Delete); await this.pageManager.workspacePage.clickDelete(); await this.pageManager.workspacePage.waitForNodeRemoved(node); } @@ -320,24 +296,16 @@ export class WorkspaceFlow extends BaseFlow { * @returns The generated code snippet's text (curl by default) */ async generateCode( - request: - | HttpRequest - | GraphQLRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + request: HttpRequest | GraphQLRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, options?: { target?: string; client?: string }, ): Promise { const { workspacePage } = this.pageManager; const node = await workspacePage.findItemNode(request); - if (!node) - throw new Error(`Failed to find "${request.name}" to generate code for`); + if (!node) throw new Error(`Failed to find "${request.name}" to generate code for`); await workspacePage.rightClick(node); await workspacePage.clickContextMenu(ContextMenuItem.GenerateCode); - if (options?.target) - await workspacePage.setGenerateCodeTarget(options.target); - if (options?.client) - await workspacePage.setGenerateCodeClient(options.client); + if (options?.target) await workspacePage.setGenerateCodeTarget(options.target); + if (options?.client) await workspacePage.setGenerateCodeClient(options.client); return workspacePage.getGeneratedCode(); } @@ -361,13 +329,7 @@ export class WorkspaceFlow extends BaseFlow { * @returns The duplicated request's id and name */ async duplicate( - item: - | HttpRequest - | GraphQLRequest - | GrpcRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + item: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, newName: string, ): Promise<{ id: string; name: string }>; /** @@ -398,19 +360,14 @@ export class WorkspaceFlow extends BaseFlow { const node = await workspace.resolveNode(item); await workspace.rightClick(node); await workspace.clickContextMenu( - node.type === TreeNodeType.Workspace - ? ContextMenuItem.DuplicateWorkspace - : ContextMenuItem.Duplicate, + node.type === TreeNodeType.Workspace ? ContextMenuItem.DuplicateWorkspace : ContextMenuItem.Duplicate, ); if (node.type === TreeNodeType.Request) { await workspace.setItemName(newName); await workspace.clickDuplicateRequest(); const newNode = await workspace.findNode(newName, TreeNodeType.Request); - return this.assertCreated( - newNode ? { id: newNode._id, name: newName } : undefined, - newName, - ); + return this.assertCreated(newNode ? { id: newNode._id, name: newName } : undefined, newName); } await workspace.setDuplicateName(newName); @@ -431,15 +388,9 @@ export class WorkspaceFlow extends BaseFlow { // populate `containerName` the way environmentFlow.create() does. if (item.name !== newName) { await this.pageManager.environmentPage.navigate(); - await this.pageManager.environmentPage.renameEnvironment( - item.name, - newName, - ); + await this.pageManager.environmentPage.renameEnvironment(item.name, newName); } - const duplicated = await this.assertCreated( - await this.getEnvironment(newName), - newName, - ); + const duplicated = await this.assertCreated(await this.getEnvironment(newName), newName); return Object.assign(duplicated, { containerName: newName }); } @@ -449,13 +400,7 @@ export class WorkspaceFlow extends BaseFlow { * @param request - The request to pin */ async pin( - request: - | HttpRequest - | GraphQLRequest - | GrpcRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + request: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, ): Promise { await this.togglePin(request, ContextMenuItem.Pin); } @@ -466,25 +411,13 @@ export class WorkspaceFlow extends BaseFlow { * @param request - The request to unpin */ async unpin( - request: - | HttpRequest - | GraphQLRequest - | GrpcRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + request: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, ): Promise { await this.togglePin(request, ContextMenuItem.Unpin); } private async togglePin( - request: - | HttpRequest - | GraphQLRequest - | GrpcRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + request: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, action: ContextMenuItem.Pin | ContextMenuItem.Unpin, ): Promise { const { workspacePage } = this.pageManager; @@ -515,13 +448,7 @@ export class WorkspaceFlow extends BaseFlow { * @returns The renamed request's id and name */ async rename( - item: - | HttpRequest - | GraphQLRequest - | GrpcRequest - | EventStreamRequest - | SocketIORequest - | WebSocketRequest, + item: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, newName: string, ): Promise<{ id: string; name: string }>; /** @@ -555,10 +482,7 @@ export class WorkspaceFlow extends BaseFlow { if (node.type === TreeNodeType.Request) { const newNode = await workspace.findNode(newName, TreeNodeType.Request); - return this.assertCreated( - newNode ? { id: newNode._id, name: newName } : undefined, - newName, - ); + return this.assertCreated(newNode ? { id: newNode._id, name: newName } : undefined, newName); } if (item instanceof Collection) { @@ -581,10 +505,7 @@ export class WorkspaceFlow extends BaseFlow { * @param collection - The Collection to run * @param options - Iterations/delay/keep-logs/bail settings to apply before running */ - async openRunner( - collection: Collection, - options?: RunnerOptions, - ): Promise { + async openRunner(collection: Collection, options?: RunnerOptions): Promise { const workspace = this.pageManager.workspacePage; const runner = this.pageManager.runnerPage; const node = await workspace.resolveNode(collection); @@ -592,8 +513,7 @@ export class WorkspaceFlow extends BaseFlow { await workspace.clickContextMenu(ContextMenuItem.RunCollection); await runner.navigate(); - if (options?.keepLogs !== undefined) - await runner.setKeepLogs(options.keepLogs); + if (options?.keepLogs !== undefined) await runner.setKeepLogs(options.keepLogs); if (options?.bail !== undefined) await runner.setBail(options.bail); if (options?.dataFilePath !== undefined) { await runner.uploadData(options.dataFilePath); @@ -617,17 +537,12 @@ export class WorkspaceFlow extends BaseFlow { * whether the request's own result row appears), each filter's array has the * same request rows; only their per-assertion detail differs */ - async run( - collection: Collection, - options?: RunnerOptions, - ): Promise { + async run(collection: Collection, options?: RunnerOptions): Promise { const runner = this.pageManager.runnerPage; await this.openRunner(collection, options); - await runner.switchRequestTab("request-order"); - const selectedRequestCount = (await runner.getRequestOrder()).filter( - (item) => item.selected, - ).length; + await runner.switchRequestTab('request-order'); + const selectedRequestCount = (await runner.getRequestOrder()).filter(item => item.selected).length; const targetIterations = await runner.getIterations(); await runner.clickRun(); @@ -636,10 +551,7 @@ export class WorkspaceFlow extends BaseFlow { .poll( async () => { const results = await runner.getIterationResults(); - return ( - results.length === targetIterations && - results.every((it) => it.results.length === selectedRequestCount) - ); + return results.length === targetIterations && results.every(it => it.results.length === selectedRequestCount); }, { timeout: DEFAULT_TIMEOUT }, ) @@ -664,13 +576,11 @@ export class WorkspaceFlow extends BaseFlow { item: string | { name: string; id?: string }, parent?: Project | Collection, ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; const parentNode = parent ? await workspace.resolveNode(parent) : undefined; const node = await workspace.findItemNode(identity, undefined, parentNode); - const result = node - ? await this.getResolved(node, { name: node.name, id: node._id }) - : undefined; + const result = node ? await this.getResolved(node, { name: node.name, id: node._id }) : undefined; return result as T | undefined; } @@ -697,10 +607,8 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The collection name, or an object with name and optional id to match * @returns The matching Collection with `id` populated, or undefined if not found */ - async getCollection( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + async getCollection(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); if (!node) return undefined; return Object.assign(new Collection(identity.name), { id: node._id }); @@ -717,10 +625,8 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The collection's name, or an object with `name`/`id` * @returns The found collection (with `specification`/`rulesetType`/`version` populated), or `undefined` if not found */ - async getCollectionSpec( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + async getCollectionSpec(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; const node = await workspace.findItemNode(identity); @@ -738,6 +644,33 @@ export class WorkspaceFlow extends BaseFlow { return collection; } + /** + * Types `spec` into an already-existing collection's spec editor, + * replacing whatever content (if any) is currently there. Unlike the + * `spec` passed to `create()` (which only authors right after + * creation), this can be called at any later point — e.g. filling in a + * collection that started out with no spec. + * @param item - The collection to author into + * @param spec - The spec to author; written verbatim when given as a string, boilerplate-wrapped into Swagger 2.0 JSON when given as a `Specification` + */ + async fillSpecification(item: Collection, spec: NonNullable): Promise { + const workspace = this.pageManager.workspacePage; + const node = await workspace.resolveNode(item); + await workspace.clickNode(node); + await workspace.navigateSpec(); + await workspace.setSpecification( + typeof spec === 'string' + ? spec + : JSON.stringify({ + swagger: '2.0', + host: 'localhost', + schemes: ['http'], + info: spec.info, + paths: spec.paths, + }), + ); + } + /** * Opens `item`'s Tests tab, then creates a default-named test suite (via * `WorkspacePage.createTestSuite()`, which also navigates into it) and @@ -746,14 +679,11 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The collection's name, or an object with `name`/`id` * @param name - The name to give the new suite */ - async createTestSuite( - item: string | { name: string; id?: string }, - name: string, - ): Promise { + async createTestSuite(item: string | { name: string; id?: string }, name: string): Promise { const workspace = this.pageManager.workspacePage; await this.openTests(item); await workspace.createTestSuite(); - await workspace.renameTestSuite("New Suite", name); + await workspace.renameTestSuite('New Suite', name); } /** @@ -770,7 +700,7 @@ export class WorkspaceFlow extends BaseFlow { await workspace.selectTestSuite(testSuite.name); await workspace.createTest(); - await workspace.renameUnitTest("Returns 200", testName); + await workspace.renameUnitTest('Returns 200', testName); } /** @@ -779,10 +709,7 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The collection's name, or an object with `name`/`id` * @param suiteName - The exact name of the suite to delete */ - async deleteTestSuite( - item: string | { name: string; id?: string }, - suiteName: string, - ): Promise { + async deleteTestSuite(item: string | { name: string; id?: string }, suiteName: string): Promise { await this.openTests(item); await this.pageManager.workspacePage.deleteTestSuite(suiteName); } @@ -800,9 +727,7 @@ export class WorkspaceFlow extends BaseFlow { * @param minLintErrors - The minimum error count to wait for before reading the settled lint state; leave at 0 when no errors are expected * @returns The settled lint summary and fired rule codes */ - async getLintState( - minLintErrors = 0, - ): Promise> { + async getLintState(minLintErrors = 0): Promise> { const workspace = this.pageManager.workspacePage; await workspace.expandLintPanel(); @@ -810,7 +735,7 @@ export class WorkspaceFlow extends BaseFlow { .poll( async () => { const summary = await workspace.getLintSummary(); - return summary === "none" ? 0 : summary.errors; + return summary === 'none' ? 0 : summary.errors; }, { timeout: DEFAULT_TIMEOUT }, ) @@ -818,8 +743,8 @@ export class WorkspaceFlow extends BaseFlow { const summary = await workspace.getLintSummary(); const entries = await workspace.getLintEntries(); return { - errors: summary === "none" ? 0 : summary.errors, - warnings: summary === "none" ? 0 : summary.warnings, + errors: summary === 'none' ? 0 : summary.errors, + warnings: summary === 'none' ? 0 : summary.warnings, entries, }; } @@ -834,24 +759,16 @@ export class WorkspaceFlow extends BaseFlow { * @param testSuiteName - The exact name of the suite to select and read * @returns The selected suite, with its unit tests and source collection populated */ - async getTestSuite( - item: string | { name: string; id?: string }, - testSuiteName: string, - ): Promise { + async getTestSuite(item: string | { name: string; id?: string }, testSuiteName: string): Promise { const workspace = this.pageManager.workspacePage; const node = await this.openTests(item); await workspace.selectTestSuite(testSuiteName); - const collection = - item instanceof Collection - ? item - : new Collection(typeof item === "string" ? item : item.name); + const collection = item instanceof Collection ? item : new Collection(typeof item === 'string' ? item : item.name); if (!collection.id && node) collection.id = node._id; const name = await workspace.getSelectedTestSuiteName(); - const tests = (await workspace.getUnitTestNames()).map( - (testName) => new UnitTest(testName), - ); + const tests = (await workspace.getUnitTestNames()).map(testName => new UnitTest(testName)); return new TestSuite(name, tests, collection); } @@ -860,9 +777,7 @@ export class WorkspaceFlow extends BaseFlow { * listed in the Test Suites sidebar (`WorkspacePage.getTestSuiteNames()`). * @param item - The collection's name, or an object with `name`/`id` */ - async getTestSuiteNames( - item: string | { name: string; id?: string }, - ): Promise { + async getTestSuiteNames(item: string | { name: string; id?: string }): Promise { await this.openTests(item); return this.pageManager.workspacePage.getTestSuiteNames(); } @@ -873,10 +788,8 @@ export class WorkspaceFlow extends BaseFlow { * the default OAS ruleset. * @param item - The collection's name, or an object with `name`/`id` */ - async removeRuleset( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + async removeRuleset(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; const node = await workspace.findItemNode(identity); @@ -916,15 +829,12 @@ export class WorkspaceFlow extends BaseFlow { * @param expectCode - A rule id the accepted ruleset should introduce, e.g. "require-x-test-marker" — omit when uploading a ruleset expected to be rejected * @returns The settled list of fired rule ids if accepted, or `"invalid"` if rejected */ - async uploadRuleset( - filePath: string, - expectCode?: string, - ): Promise { + async uploadRuleset(filePath: string, expectCode?: string): Promise { const workspace = this.pageManager.workspacePage; const result = await workspace.uploadRuleset(filePath); - if (result === "invalid") { + if (result === 'invalid') { await workspace.closeDialog(); - return "invalid"; + return 'invalid'; } await workspace.expandLintPanel(); @@ -938,11 +848,7 @@ export class WorkspaceFlow extends BaseFlow { return workspace.getLintEntryCodes(); } - private async createCollection( - parent: Project, - name: string, - fileName?: string, - ): Promise { + private async createCollection(parent: Project, name: string, fileName?: string): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); await workspace.rightClick(node); @@ -954,9 +860,7 @@ export class WorkspaceFlow extends BaseFlow { // A file name means this collection is backed by its own file in a Git // Sync project, which writes and stages that file before the dialog // closes — give it more room than the default UI-only timeout. - await workspace.clickCreate( - fileName !== undefined ? DEFAULT_TIMEOUT * 2 : undefined, - ); + await workspace.clickCreate(fileName !== undefined ? DEFAULT_TIMEOUT * 2 : undefined); } /** @@ -967,31 +871,25 @@ export class WorkspaceFlow extends BaseFlow { * @param name - The name of the just-created collection to author into * @param spec - The spec to author */ - private async authorSpec( - name: string, - spec: NonNullable, - ): Promise { + private async authorSpec(name: string, spec: NonNullable): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.findItemNode({ name }); await workspace.clickNode(node!); await workspace.navigateSpec(); await workspace.setSpecification( - typeof spec === "string" + typeof spec === 'string' ? spec : JSON.stringify({ - swagger: "2.0", - host: "localhost", - schemes: ["http"], + swagger: '2.0', + host: 'localhost', + schemes: ['http'], info: spec.info, paths: spec.paths, }), ); } - private async createEnvironment( - parent: Project, - name: string, - ): Promise { + private async createEnvironment(parent: Project, name: string): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); await workspace.rightClick(node); @@ -1046,10 +944,10 @@ export class WorkspaceFlow extends BaseFlow { const uri = repo?.uri ?? this.gitRepoUrl; if (!uri) { throw new Error( - "create(): no repo URI given for a Git Sync project — either pass repo.uri, or use the `user` fixture from misc/git-fixtures.ts, which injects one", + 'create(): no repo URI given for a Git Sync project — either pass repo.uri, or use the `user` fixture from misc/git-fixtures.ts, which injects one', ); } - const branch = repo?.branch ?? "master"; + const branch = repo?.branch ?? 'master'; const { workspacePage, projectSettingsPage } = this.pageManager; await workspacePage.clickNewProject(); @@ -1065,16 +963,11 @@ export class WorkspaceFlow extends BaseFlow { await projectSettingsPage.submitScanForFiles(); await projectSettingsPage.confirmClone(); - return this.assertCreated( - await this.getProject(project.name), - project.name, - ); + return this.assertCreated(await this.getProject(project.name), project.name); } - private async getEnvironment( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + private async getEnvironment(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); if (!node) return undefined; return Object.assign(new Environment({ name: identity.name }), { @@ -1087,10 +980,8 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The MCP Client name, or an object with name and optional id to match * @returns The matching McpClient with `id` populated, or undefined if not found */ - private async getMcpClient( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + private async getMcpClient(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); if (!node) return undefined; const mcpClient = new McpClient(identity.name); @@ -1103,10 +994,8 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The project name, or an object with name and optional id to match * @returns The matching Project with `id` populated, or undefined if not found */ - async getProject( - item: string | { name: string; id?: string }, - ): Promise { - const identity = typeof item === "string" ? { name: item } : item; + async getProject(item: string | { name: string; id?: string }): Promise { + const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); if (!node) return undefined; return Object.assign(new Project(identity.name, ProjectType.Local), { @@ -1122,49 +1011,47 @@ export class WorkspaceFlow extends BaseFlow { if (node.type === TreeNodeType.Project) { const project = await this.getProject(resolved); - return project && { ...project, kind: "project" }; + return project && { ...project, kind: 'project' }; } if (node.type === TreeNodeType.Request) { const flows = this.flowManager; const label = await workspace.getRequestTypeLabel(node); switch (label) { - case "GQL": { + case 'GQL': { const request = await flows.graphqlRequestFlow.get(resolved); - return request && { ...request, kind: "graphql" }; + return request && { ...request, kind: 'graphql' }; } - case "gRPC": { + case 'gRPC': { const request = await flows.grpcRequestFlow.get(resolved); - return request && { ...request, kind: "grpc" }; + return request && { ...request, kind: 'grpc' }; } - case "SSE": { + case 'SSE': { const request = await flows.eventStreamRequestFlow.get(resolved); - return request && { ...request, kind: "eventStream" }; + return request && { ...request, kind: 'eventStream' }; } - case "IO": { + case 'IO': { const request = await flows.socketIoRequestFlow.get(resolved); - return request && { ...request, kind: "socketIO" }; + return request && { ...request, kind: 'socketIO' }; } - case "WS": { + case 'WS': { const request = await flows.webSocketRequestFlow.get(resolved); - return request && { ...request, kind: "webSocket" }; + return request && { ...request, kind: 'webSocket' }; } default: { const request = await flows.httpRequestFlow.get(resolved); - return request && { ...request, kind: "http" }; + return request && { ...request, kind: 'http' }; } } } const subKind = await workspace.getWorkspaceItemKind(node); - if (subKind === "mcpClient") { + if (subKind === 'mcpClient') { const mcpClient = await this.getMcpClient(resolved); - return ( - mcpClient && Object.assign(mcpClient, { kind: "mcpClient" as const }) - ); + return mcpClient && Object.assign(mcpClient, { kind: 'mcpClient' as const }); } const collection = await this.getCollection(resolved); - return collection && { ...collection, kind: "collection" }; + return collection && { ...collection, kind: 'collection' }; } /** @@ -1182,9 +1069,7 @@ export class WorkspaceFlow extends BaseFlow { */ private async ensureLegacyUnitTestsEnabled(): Promise { if (this.legacyUnitTestsEnabled) return; - await this.flowManager.preferencesFlow.set( - new Settings({ showLegacyUnitTests: true }), - ); + await this.flowManager.preferencesFlow.set(new Settings({ showLegacyUnitTests: true })); this.legacyUnitTestsEnabled = true; } @@ -1197,12 +1082,10 @@ export class WorkspaceFlow extends BaseFlow { * @param item - The collection's name, or an object with `name`/`id` * @returns The resolved tree node, or `undefined` if `item` couldn't be found */ - private async openTests( - item: string | { name: string; id?: string }, - ): Promise<{ _id: string } | undefined> { + private async openTests(item: string | { name: string; id?: string }): Promise<{ _id: string } | undefined> { await this.ensureLegacyUnitTestsEnabled(); - const identity = typeof item === "string" ? { name: item } : item; + const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; const node = await workspace.findItemNode(identity); diff --git a/packages/insomnia-test/pages/export.page.ts b/packages/insomnia-test/pages/export.page.ts index ffbee77b82..becdff6df6 100644 --- a/packages/insomnia-test/pages/export.page.ts +++ b/packages/insomnia-test/pages/export.page.ts @@ -1,6 +1,8 @@ import { expect } from "@playwright/test"; +import type { ApiSpecExportFormat } from "../enums/api-spec-export-format"; import type { ExportFormat } from "../enums/export-format"; +import { throwOnDialog } from "../misc/decorators"; import { DEFAULT_TIMEOUT } from "../misc/fixtures"; import { BasePage } from "./base.page"; @@ -13,6 +15,10 @@ export class ExportPage extends BasePage { .getByRole("dialog") .filter({ hasText: "Select Export Type" }); + private readonly selectSpecFormatDialog = this.page + .getByRole("dialog") + .filter({ hasText: "Select Specification Format" }); + /** * Confirms either the "Export requests" or "Select Export Type" dialog * has appeared, whichever this export scope opens first — a Collection @@ -65,6 +71,34 @@ export class ExportPage extends BasePage { }); } + /** + * Handles whatever "Export OpenAPI Spec" opened: if the "Select + * Specification Format" modal appeared (the workspace had a spec), + * chooses `format` and clicks "Done", writing the file via the + * stubbed native save dialog. Otherwise, a "Cannot export" alert must + * have appeared instead (the spec was empty/missing), so this is a + * no-op — the alert is left open, and `@throwOnDialog` (below) then + * rejects with its trimmed text once this method returns. + * @param format - The format to export the spec as + */ + @throwOnDialog() + async exportOpenApiSpec(format: ApiSpecExportFormat): Promise { + await expect(this.page.getByRole("dialog").first()).toBeVisible({ + timeout: DEFAULT_TIMEOUT, + }); + if (!(await this.selectSpecFormatDialog.isVisible())) return; + + await this.selectSpecFormatDialog + .locator("select") + .selectOption({ label: format }); + await this.selectSpecFormatDialog + .getByRole("button", { name: "Done" }) + .click(); + await expect(this.selectSpecFormatDialog).toBeHidden({ + timeout: DEFAULT_TIMEOUT, + }); + } + /** * Dismisses the "Export Complete" confirmation dialog if one appears — * only shown for a multi-file (Project/all-data) export, and even then diff --git a/packages/insomnia-test/pages/workspace.page.ts b/packages/insomnia-test/pages/workspace.page.ts index dd344bdd69..3dd614f981 100644 --- a/packages/insomnia-test/pages/workspace.page.ts +++ b/packages/insomnia-test/pages/workspace.page.ts @@ -366,17 +366,43 @@ export class WorkspacePage extends BasePage { await this.page.locator(this.ADD_TAB_BUTTON).click(); } + /** + * Locates a context-menu item by name, matching either a menuitem or a + * menuitemradio role since some menu entries render as radio options. + * Exposed (unlike `clickContextMenu`) so a caller can assert visibility + * without clicking, e.g. confirming an item is offered on a given + * surface. + * @param item - The context-menu item to locate + */ + getContextMenuItem(item: ContextMenuItem): Locator { + const menu = this.page.getByRole("menu"); + return menu + .getByRole("menuitem", { name: item, exact: true }) + .or(menu.getByRole("menuitemradio", { name: item, exact: true })); + } + /** * Clicks a context-menu item, matching either a menuitem or a * menuitemradio role since some menu entries render as radio options. * @param item - The context-menu item to click */ async clickContextMenu(item: ContextMenuItem): Promise { - const menu = this.page.getByRole("menu"); - const menuItem = menu - .getByRole("menuitem", { name: item, exact: true }) - .or(menu.getByRole("menuitemradio", { name: item, exact: true })); - await menuItem.click(); + await this.getContextMenuItem(item).click(); + } + + /** + * Opens a workspace row's "⋮" actions dropdown (the "SideBar Workspace + * Actions" trigger, only rendered on hover) — the same underlying menu + * `rightClick()` also opens. + * @param node - The workspace tree node to open the dropdown for + */ + async openWorkspaceActionsDropdown(node: TreeNode): Promise { + const row = this.page + .getByTestId("project-navigation-tree-container") + .locator(`div[role="row"][data-key="${node._id}"]`); + await row.scrollIntoViewIfNeeded(); + await row.hover(); + await row.getByLabel("SideBar Workspace Actions").click(); } /** diff --git a/packages/insomnia-test/tests/data/export/export-openapi-spec.spec.ts b/packages/insomnia-test/tests/data/export/export-openapi-spec.spec.ts new file mode 100644 index 0000000000..5ea2d4aded --- /dev/null +++ b/packages/insomnia-test/tests/data/export/export-openapi-spec.spec.ts @@ -0,0 +1,75 @@ +import * as os from 'node:os'; +import path from 'node:path'; + +import { faker } from '@faker-js/faker'; + +import { ApiSpecExportFormat } from '../../../enums/api-spec-export-format'; +import { ProjectType } from '../../../enums/project-types'; +import { waitForExportedFile } from '../../../misc/export-file'; +import { expect, test } from '../../../misc/fixtures'; +import { Collection } from '../../../models/collection'; +import { Project } from '../../../models/project'; + +const petStoreSpec = (title: string) => + `openapi: 3.0.4 +info: + title: ${title} + version: 1.0.0 +paths: + /pets: + get: + responses: + '200': + description: OK +`; + +test("Verify exporting an API Collection's OpenAPI spec as YAML and JSON from the sidebar dropdown", async ({ + user, +}) => { + const { workspaceFlow, exportFlow } = user.flowManager; + + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Local)); + const title = faker.commerce.productName(); + const collection = await workspaceFlow.create( + project, + new Collection(faker.string.alphanumeric(10), petStoreSpec(title)), + ); + + const yamlPath = path.join(os.tmpdir(), `${faker.string.alphanumeric(10)}.yaml`); + await exportFlow.exportOpenApiSpec(collection, ApiSpecExportFormat.Yaml, yamlPath); + const yamlContent = await waitForExportedFile(yamlPath); + const jsonPath = path.join(os.tmpdir(), `${faker.string.alphanumeric(10)}.json`); + await exportFlow.exportOpenApiSpec(collection, ApiSpecExportFormat.Json, jsonPath); + const jsonContent = await waitForExportedFile(jsonPath); + const json = JSON.parse(jsonContent); + + expect(yamlContent).toContain('openapi: 3.0.4'); + expect(yamlContent).toContain(title); + expect(json).toMatchObject({ openapi: '3.0.4' }); + expect(json.info.title).toBe(title); +}); + +test("Verify exporting shows 'Cannot export' while the spec is empty, then succeeds once the spec is filled in", async ({ + user, +}) => { + const { workspaceFlow, exportFlow } = user.flowManager; + + const project = await workspaceFlow.create(new Project(faker.string.alphanumeric(10), ProjectType.Local)); + const collection = await workspaceFlow.create(project, new Collection(faker.string.alphanumeric(10))); + + const emptyPath = path.join(os.tmpdir(), `${faker.string.alphanumeric(10)}.yaml`); + await expect(exportFlow.exportOpenApiSpec(collection, ApiSpecExportFormat.Yaml, emptyPath)).rejects.toThrow( + 'does not contain an OpenAPI specification to export', + ); + await workspaceFlow.closeDialog(); + + const title = faker.commerce.productName(); + await workspaceFlow.fillSpecification(collection, petStoreSpec(title)); + + const yamlPath = path.join(os.tmpdir(), `${faker.string.alphanumeric(10)}.yaml`); + await exportFlow.exportOpenApiSpec(collection, ApiSpecExportFormat.Yaml, yamlPath); + const yamlContent = await waitForExportedFile(yamlPath); + + expect(yamlContent).toContain('openapi: 3.0.4'); + expect(yamlContent).toContain(title); +});