mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 20:43:52 -05:00
feat: add export functionality for OpenAPI spec in YAML and JSON formats
- Introduced a new context menu item for exporting OpenAPI specs. - Implemented exportOpenApiSpec method in ExportFlow to handle the export process. - Created a new ApiSpecExportFormat enum for specifying export formats. - Enhanced ExportPage to manage the export dialog for OpenAPI specs. - Added tests to verify exporting OpenAPI specs as YAML and JSON, including handling of empty specs.
This commit is contained in:
8 files changed
+345
-269
No files matched your search
@@ -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<void>
|
||||
// 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<void>
|
||||
// via Preferences -> Data -> "Export...<name>...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<void>
|
||||
// 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<void>` — 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<void>` — 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<Collection["spec"]>): Promise<void>` — 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 `<x>RequestFlow.create(collection, ...)` pattern documented elsewhere in this file for those.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ApiSpecExportFormat {
|
||||
Yaml = "YAML",
|
||||
Json = "JSON",
|
||||
}
|
||||
@@ -32,5 +32,6 @@ export enum ContextMenuItem {
|
||||
Unpin = "Unpin",
|
||||
OpenInNewTab = "Open in New Tab",
|
||||
Export = "Export",
|
||||
ExportOpenApiSpec = "Export OpenAPI Spec",
|
||||
GenerateCode = "Generate Code",
|
||||
}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Collection>;
|
||||
async create(parent: Project, item: Collection, fileName?: string): Promise<Collection>;
|
||||
/**
|
||||
* 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<Collection> {
|
||||
async createInEmptyState(parent: Project, collection: Collection): Promise<Collection> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async openRunner(collection: Collection, options?: RunnerOptions): Promise<void> {
|
||||
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<RunnerRunResult> {
|
||||
async run(collection: Collection, options?: RunnerOptions): Promise<RunnerRunResult> {
|
||||
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<T | undefined> {
|
||||
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<Collection | undefined> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
async getCollection(item: string | { name: string; id?: string }): Promise<Collection | undefined> {
|
||||
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<Collection | undefined> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
async getCollectionSpec(item: string | { name: string; id?: string }): Promise<Collection | undefined> {
|
||||
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<Collection['spec']>): Promise<void> {
|
||||
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<void> {
|
||||
async createTestSuite(item: string | { name: string; id?: string }, name: string): Promise<void> {
|
||||
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<void> {
|
||||
async deleteTestSuite(item: string | { name: string; id?: string }, suiteName: string): Promise<void> {
|
||||
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<NonNullable<Collection["lint"]>> {
|
||||
async getLintState(minLintErrors = 0): Promise<NonNullable<Collection['lint']>> {
|
||||
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<TestSuite> {
|
||||
async getTestSuite(item: string | { name: string; id?: string }, testSuiteName: string): Promise<TestSuite> {
|
||||
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<string[]> {
|
||||
async getTestSuiteNames(item: string | { name: string; id?: string }): Promise<string[]> {
|
||||
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<void> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
async removeRuleset(item: string | { name: string; id?: string }): Promise<void> {
|
||||
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<string[] | "invalid"> {
|
||||
async uploadRuleset(filePath: string, expectCode?: string): Promise<string[] | 'invalid'> {
|
||||
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<void> {
|
||||
private async createCollection(parent: Project, name: string, fileName?: string): Promise<void> {
|
||||
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<Collection["spec"]>,
|
||||
): Promise<void> {
|
||||
private async authorSpec(name: string, spec: NonNullable<Collection['spec']>): Promise<void> {
|
||||
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<void> {
|
||||
private async createEnvironment(parent: Project, name: string): Promise<void> {
|
||||
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<Environment | undefined> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
private async getEnvironment(item: string | { name: string; id?: string }): Promise<Environment | undefined> {
|
||||
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<McpClient | undefined> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
private async getMcpClient(item: string | { name: string; id?: string }): Promise<McpClient | undefined> {
|
||||
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<Project | undefined> {
|
||||
const identity = typeof item === "string" ? { name: item } : item;
|
||||
async getProject(item: string | { name: string; id?: string }): Promise<Project | undefined> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user