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:
Bob Qiu committed 2026-09-30 14:41:21 +08:00
1 parent f4eb699fe2
commit 618aac7928
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);
}
}
+145 -262
View File
@@ -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
+31 -5
View File
@@ -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);
});