From e32d8cb4e1059437eaf8274d35c19b757c94307d Mon Sep 17 00:00:00 2001 From: Bob Qiu Date: Fri, 30 Oct 2026 17:40:37 +0800 Subject: [PATCH] feat: add step instrumentation for flow methods - Introduced a new `step` decorator to enhance method reporting in flows. - Removed the old step instrumentation logic from `step-instrumentation.ts`. - Updated various flow classes to utilize the new `@step()` decorator for method tracking. - Simplified page manager by removing the instrumentWithSteps function. - Adjusted tests to ensure compatibility with the new step reporting mechanism. --- packages/insomnia-data/package.json | 1 + packages/insomnia-test/flows/app.flow.ts | 9 + .../insomnia-test/flows/certificates.flow.ts | 6 + .../insomnia-test/flows/cloud-sync.flow.ts | 12 + .../insomnia-test/flows/environment.flow.ts | 9 + .../flows/event-stream-request.flow.ts | 6 + packages/insomnia-test/flows/export.flow.ts | 5 + packages/insomnia-test/flows/flow-manager.ts | 91 ++----- packages/insomnia-test/flows/folder.flow.ts | 6 + packages/insomnia-test/flows/git-sync.flow.ts | 25 ++ .../flows/graphql-request.flow.ts | 6 + .../insomnia-test/flows/grpc-request.flow.ts | 9 + .../insomnia-test/flows/http-request.flow.ts | 9 + packages/insomnia-test/flows/import.flow.ts | 6 + .../insomnia-test/flows/mcp-client.flow.ts | 14 + .../insomnia-test/flows/mock-server.flow.ts | 17 ++ .../insomnia-test/flows/organization.flow.ts | 2 + .../insomnia-test/flows/preferences.flow.ts | 9 + .../flows/socket-io-request.flow.ts | 6 + .../insomnia-test/flows/template-tag.flow.ts | 5 + .../flows/variable-source.flow.ts | 17 ++ .../flows/web-socket-request.flow.ts | 7 + .../insomnia-test/flows/workspace.flow.ts | 38 +++ packages/insomnia-test/misc/fixtures.ts | 2 - .../misc/step-instrumentation.ts | 241 ------------------ packages/insomnia-test/misc/step.ts | 104 ++++++++ packages/insomnia-test/pages/page-manager.ts | 4 +- packages/insomnia-test/playwright.config.ts | 2 +- 28 files changed, 353 insertions(+), 315 deletions(-) delete mode 100644 packages/insomnia-test/misc/step-instrumentation.ts create mode 100644 packages/insomnia-test/misc/step.ts diff --git a/packages/insomnia-data/package.json b/packages/insomnia-data/package.json index 0911bedd1d..5e9a2eb92d 100644 --- a/packages/insomnia-data/package.json +++ b/packages/insomnia-data/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "name": "insomnia-data", "license": "Apache-2.0", "version": "13.3.1", diff --git a/packages/insomnia-test/flows/app.flow.ts b/packages/insomnia-test/flows/app.flow.ts index 2f5aee04af..023e5108a7 100644 --- a/packages/insomnia-test/flows/app.flow.ts +++ b/packages/insomnia-test/flows/app.flow.ts @@ -8,6 +8,7 @@ import { trackHiddenWindowCoverage, } from "../misc/coverage"; import { launchInsomniaElectron } from "../misc/launch"; +import { step } from "../misc/step"; import { BaseFlow } from "./base.flow"; export class AppFlow extends BaseFlow { @@ -18,6 +19,7 @@ export class AppFlow extends BaseFlow { * `flowManager`/`pageManager` onto the new app in place, so the same * `user.flowManager`/`user.pageManager` instances keep working. */ + @step() async restart(): Promise { const config = this.flowManager.launchConfig; if (!config) { @@ -61,6 +63,7 @@ export class AppFlow extends BaseFlow { * `setImmediate` before returning, so the error has been fully * handled by the time this resolves. */ + @step() async emitStdoutEpipeError(): Promise { await this.flowManager.electronApp!.evaluate(() => { process.nextTick(() => { @@ -79,6 +82,7 @@ export class AppFlow extends BaseFlow { * event on the main process's `process.stdout` (e.g. `"error"`, * `"close"`, `"drain"`). */ + @step() async getStdoutListenerCount(event: string): Promise { return this.flowManager.electronApp!.evaluate( (_electron, evt) => process.stdout.listenerCount(evt), @@ -91,6 +95,7 @@ export class AppFlow extends BaseFlow { * trivial `evaluate()` call. If the main process had crashed, this * call itself would reject with a "Target closed" error. */ + @step() async isAlive(): Promise { return this.flowManager.electronApp!.evaluate(() => true); } @@ -99,6 +104,7 @@ export class AppFlow extends BaseFlow { * Reads the main process's `userData` directory — the same path * Insomnia backs up its NeDB `.db` files under (`/backups`). */ + @step() async getDataPath(): Promise { return this.flowManager.electronApp!.evaluate(({ app }) => app.getPath("userData"), @@ -112,6 +118,7 @@ export class AppFlow extends BaseFlow { * "check for remote changes" logic on a real focus change, without * actually needing to blur/refocus the Electron window from the OS. */ + @step() async simulateMainWindowFocusChange(): Promise { await this.flowManager.electronApp!.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) { @@ -127,6 +134,7 @@ export class AppFlow extends BaseFlow { * them in order. Only lines written during this call are captured — * nothing from before it started. */ + @step() async captureMainProcessLog(action: () => Promise): Promise { const lines: string[] = []; const proc = this.flowManager.electronApp!.process(); @@ -153,6 +161,7 @@ export class AppFlow extends BaseFlow { * to run. * @throws If no window with that title is currently open */ + @step() async closeHiddenScriptWindow(): Promise { for (const win of await this.flowManager.electronApp!.windows()) { if ((await win.title().catch(() => "")) === "Hidden Browser Window") { diff --git a/packages/insomnia-test/flows/certificates.flow.ts b/packages/insomnia-test/flows/certificates.flow.ts index c414fae6d8..3a0ab20d44 100644 --- a/packages/insomnia-test/flows/certificates.flow.ts +++ b/packages/insomnia-test/flows/certificates.flow.ts @@ -1,3 +1,4 @@ +import { step } from "../misc/step"; import type { ClientCertificate } from "../models/client-certificate"; import type { Collection } from "../models/collection"; import type { EventStreamRequest } from "../models/event-stream-request"; @@ -25,6 +26,7 @@ export class CertificatesFlow extends BaseFlow { * Certificates" dialog from `item`'s location. Overwrites any CA * certificate already set. */ + @step() async setCaCertificate(item: CertificateTarget, path: string): Promise { await this.openTarget(item); @@ -38,6 +40,7 @@ export class CertificatesFlow extends BaseFlow { * Adds a client certificate to `item`'s workspace, opening the "Manage * Certificates" dialog from `item`'s location. */ + @step() async addClientCertificate( item: CertificateTarget, certificate: ClientCertificate, @@ -55,6 +58,7 @@ export class CertificatesFlow extends BaseFlow { * its `host`, opening the "Manage Certificates" dialog from `item`'s * location. */ + @step() async setClientCertificateEnabled( item: CertificateTarget, host: string, @@ -78,6 +82,7 @@ export class CertificatesFlow extends BaseFlow { * Toggles `item`'s workspace-wide CA Certificate's Enabled/Disabled * state, opening the "Manage Certificates" dialog from `item`'s location. */ + @step() async setCaCertificateEnabled( item: CertificateTarget, enabled: boolean, @@ -94,6 +99,7 @@ export class CertificatesFlow extends BaseFlow { * Toggles an MCP Client's "SSL Certificate Validation" switch, opening * the "Manage Certificates" dialog from the client's location. */ + @step() async setSslValidationEnabled( client: McpClient, enabled: boolean, diff --git a/packages/insomnia-test/flows/cloud-sync.flow.ts b/packages/insomnia-test/flows/cloud-sync.flow.ts index aad86aaf92..90aa0ed254 100644 --- a/packages/insomnia-test/flows/cloud-sync.flow.ts +++ b/packages/insomnia-test/flows/cloud-sync.flow.ts @@ -1,4 +1,5 @@ import { DeleteMode } from "../enums/delete-mode"; +import { step } from "../misc/step"; import type { Project } from "../models/project"; import { BaseFlow } from "./base.flow"; @@ -8,6 +9,7 @@ export class CloudSyncFlow extends BaseFlow { * where synced, local-only, and unsynced remote files are all shown as * cards side by side. */ + @step() async openProjectDashboard(project: Project): Promise { const { workspacePage, cloudSyncPage } = this.pageManager; const node = await workspacePage.requireItemNode({ name: project.name }); @@ -19,6 +21,7 @@ export class CloudSyncFlow extends BaseFlow { * Fetches a workspace that only exists on the Cloud Sync backend into * the local sidebar and navigates into it. */ + @step() async fetch(name: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.backToAllProjects(); @@ -29,6 +32,7 @@ export class CloudSyncFlow extends BaseFlow { * Discards every uncommitted local change on the current Cloud-synced * workspace. */ + @step() async discardAllChanges(): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.discardAllChanges(); @@ -37,6 +41,7 @@ export class CloudSyncFlow extends BaseFlow { /** * Stages `requestName`, commits with `message`, then pushes. */ + @step() async commitAndPush(requestName: string, message: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openCommitDialog(); @@ -46,6 +51,7 @@ export class CloudSyncFlow extends BaseFlow { /** * Stages `requestName` and commits with `message`, without pushing. */ + @step() async commit(requestName: string, message: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openCommitDialog(); @@ -56,6 +62,7 @@ export class CloudSyncFlow extends BaseFlow { * Restores the snapshot with commit message `message` from the current * workspace's History. */ + @step() async restoreSnapshot(message: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openHistory(); @@ -67,6 +74,7 @@ export class CloudSyncFlow extends BaseFlow { * Creates a new branch named `branch` off whichever branch is * currently checked out on the current Cloud-synced workspace. */ + @step() async createBranch(branch: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openBranchesDialog(); @@ -78,6 +86,7 @@ export class CloudSyncFlow extends BaseFlow { * Merges `branch` into whichever branch is currently checked out on * the current Cloud-synced workspace. */ + @step() async mergeBranch(branch: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openBranchesDialog(); @@ -89,6 +98,7 @@ export class CloudSyncFlow extends BaseFlow { * Merges `branch` into whichever branch is currently checked out, * expecting conflicts, and resolves all of them by taking the same side. */ + @step() async mergeBranchResolvingConflicts( branch: string, version: "Current" | "Incoming", @@ -107,6 +117,7 @@ export class CloudSyncFlow extends BaseFlow { * afterward) or "Delete Permanently" (removed both locally and on the * backend). */ + @step() async delete(name: string, mode: DeleteMode): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openDeleteWorkspaceDialog(name); @@ -119,6 +130,7 @@ export class CloudSyncFlow extends BaseFlow { * archiving the backend project by its own id (no local pull required). * Requires `openProjectDashboard()` to have already navigated there. */ + @step() async deleteUnsyncedFile(name: string): Promise { const { cloudSyncPage } = this.pageManager; await cloudSyncPage.openFileDeleteDialog(name); diff --git a/packages/insomnia-test/flows/environment.flow.ts b/packages/insomnia-test/flows/environment.flow.ts index 0d074c45a3..d97f473063 100644 --- a/packages/insomnia-test/flows/environment.flow.ts +++ b/packages/insomnia-test/flows/environment.flow.ts @@ -1,3 +1,4 @@ +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import { Environment, @@ -31,6 +32,7 @@ export class EnvironmentFlow extends BaseFlow { * Creates/renames the workspace's Base Environment under `parent`, then * applies `environment.kvPairData`. */ + @step() async createBaseEnvironment(parent: Project, environment: Environment): Promise { await this.workspaceFlow.createEnvironment(parent, environment); if (environment.name !== BASE_ENVIRONMENT_NAME) { @@ -47,6 +49,7 @@ export class EnvironmentFlow extends BaseFlow { * Creates a sub-environment nested under `parent`, then applies * `environment.kvPairData`. */ + @step() async createSubEnvironment(parent: Environment, environment: Environment): Promise { await this.pageManager.environmentPage.createSubEnvironment( environment.name, @@ -59,6 +62,7 @@ export class EnvironmentFlow extends BaseFlow { /** * Finds an environment by name/id and reads its variables back. */ + @step() async get( item: string | { name: string; id?: string }, ): Promise { @@ -88,6 +92,7 @@ export class EnvironmentFlow extends BaseFlow { * Links a project Environment into `item` via the "Select a Project * Environment" picker. */ + @step() async link( item: | Collection @@ -123,6 +128,7 @@ export class EnvironmentFlow extends BaseFlow { * Distinct from `link()`, which instead attaches a separate Project * Environment; both can be active on the same collection at once. */ + @step() async activate(collection: Collection, name: string): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(collection); @@ -134,6 +140,7 @@ export class EnvironmentFlow extends BaseFlow { * Opens one environment picker on `collection`'s toolbar and reports * which picker popovers are open, then dismisses it. */ + @step() async getOpenPickersAfterOpening( collection: Collection, picker: "project" | "collection", @@ -153,6 +160,7 @@ export class EnvironmentFlow extends BaseFlow { * Creates a Project Environment from `collection`'s project picker and * reads the picker back, without linking the new environment. */ + @step() async addProjectEnvironmentFromPicker( collection: Collection, name: string, @@ -170,6 +178,7 @@ export class EnvironmentFlow extends BaseFlow { * Creates a shared or private sub-environment inside `collection`'s own * "Manage Environments" modal and reports whether it is marked private. */ + @step() async createCollectionSubEnvironment( collection: Collection, environment: Environment, diff --git a/packages/insomnia-test/flows/event-stream-request.flow.ts b/packages/insomnia-test/flows/event-stream-request.flow.ts index 633a1ba6b4..91e75a17fa 100644 --- a/packages/insomnia-test/flows/event-stream-request.flow.ts +++ b/packages/insomnia-test/flows/event-stream-request.flow.ts @@ -2,6 +2,7 @@ import type { RequestAuthentication } from "insomnia-data"; import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import { EventStreamRequest } from "../models/event-stream-request"; import type { Folder } from "../models/folder"; @@ -15,6 +16,7 @@ export class EventStreamRequestFlow extends BaseFlow { * Creates an Event Stream request under `parent` via the standalone * project tree, then verifies it was created. */ + @step() async create( parent: Collection | Folder, request: EventStreamRequest, @@ -45,6 +47,7 @@ export class EventStreamRequestFlow extends BaseFlow { * Reads back an existing Event Stream request's fields via the * standalone project tree. */ + @step() async get( item: string | { name: string; id?: string }, parent?: Collection, @@ -71,6 +74,7 @@ export class EventStreamRequestFlow extends BaseFlow { * Opens `request`, clicks its Auth tab's token-fetch button and reads * the resulting tokens back. */ + @step() async fetchOAuth2Tokens(request: EventStreamRequest): Promise { const workspace = this.pageManager.workspacePage; const page = this.pageManager.eventStreamRequestPage; @@ -86,6 +90,7 @@ export class EventStreamRequestFlow extends BaseFlow { /** * Opens `request` and applies `authentication` on its Auth tab. */ + @step() async setAuthentication( request: EventStreamRequest, authentication: RequestAuthentication, @@ -103,6 +108,7 @@ export class EventStreamRequestFlow extends BaseFlow { * Connects the event stream for `request` and reads back the resulting * response via the standalone project tree. */ + @step() async send( request: EventStreamRequest, options?: { expectConnected?: boolean }, diff --git a/packages/insomnia-test/flows/export.flow.ts b/packages/insomnia-test/flows/export.flow.ts index 626039f776..51bbfc380b 100644 --- a/packages/insomnia-test/flows/export.flow.ts +++ b/packages/insomnia-test/flows/export.flow.ts @@ -1,6 +1,7 @@ import type { ApiSpecExportFormat } from "../enums/api-spec-export-format"; import { ContextMenuItem } from "../enums/context-menu-items"; import type { ExportFormat } from "../enums/export-format"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Project } from "../models/project"; import { BaseFlow } from "./base.flow"; @@ -11,6 +12,7 @@ export class ExportFlow extends BaseFlow { * "Export" context-menu item, confirming the request-selection step * (every request stays checked). */ + @step() async exportCollection(item: Collection, format: ExportFormat, path: string): Promise { const { workspacePage, exportPage } = this.pageManager; const node = await workspacePage.resolveNode(item); @@ -28,6 +30,7 @@ export class ExportFlow extends BaseFlow { * each) via Preferences -> Data, selecting `format`. Skips the * request-selection step, which only Collection-scoped exports show. */ + @step() async exportProject(item: Project, format: ExportFormat, path: string): Promise { const { workspacePage, preferencesPage, exportPage } = this.pageManager; await workspacePage.clickNode(await workspacePage.resolveNode(item)); @@ -47,6 +50,7 @@ export class ExportFlow extends BaseFlow { * workspace) via Preferences -> Data. Always writes as Insomnia v5 — * this entry point has no request-selection or format-selection step. */ + @step() async exportAllData(directoryPath: string): Promise { const { preferencesPage, exportPage } = this.pageManager; await exportPage.stubSaveDirectoryLocation(directoryPath); @@ -67,6 +71,7 @@ export class ExportFlow extends BaseFlow { * so this rejects with that alert's trimmed text in that case; dismiss * it with `workspaceFlow.closeDialog()` before continuing. */ + @step() async exportOpenApiSpec( item: Collection, format: ApiSpecExportFormat, diff --git a/packages/insomnia-test/flows/flow-manager.ts b/packages/insomnia-test/flows/flow-manager.ts index ee8e7a9e3c..385e2883ba 100644 --- a/packages/insomnia-test/flows/flow-manager.ts +++ b/packages/insomnia-test/flows/flow-manager.ts @@ -1,6 +1,5 @@ import type { ElectronApplication } from "@playwright/test"; -import { instrumentWithSteps } from "../misc/step-instrumentation"; import type { PageManager } from "../pages/page-manager"; import { AppFlow } from "./app.flow"; import { CertificatesFlow } from "./certificates.flow"; @@ -89,140 +88,96 @@ export class FlowManager implements FlowContext { } get appFlow(): AppFlow { - return (this._appFlow ??= instrumentWithSteps( - new AppFlow(this, this.pageManager), - )); + return (this._appFlow ??= new AppFlow(this, this.pageManager)); } get certificatesFlow(): CertificatesFlow { - return (this._certificatesFlow ??= instrumentWithSteps( - new CertificatesFlow(this, this.pageManager), - )); + return (this._certificatesFlow ??= new CertificatesFlow(this, this.pageManager)); } get cloudSyncFlow(): CloudSyncFlow { - return (this._cloudSyncFlow ??= instrumentWithSteps( - new CloudSyncFlow(this, this.pageManager), - )); + return (this._cloudSyncFlow ??= new CloudSyncFlow(this, this.pageManager)); } get cookieFlow(): CookieFlow { - return (this._cookieFlow ??= instrumentWithSteps( - new CookieFlow(this, this.pageManager), - )); + return (this._cookieFlow ??= new CookieFlow(this, this.pageManager)); } get environmentFlow(): EnvironmentFlow { - return (this._environmentFlow ??= instrumentWithSteps( - new EnvironmentFlow(this, this.pageManager, this.workspaceFlow), - )); + return (this._environmentFlow ??= new EnvironmentFlow(this, this.pageManager, this.workspaceFlow)); } get eventStreamRequestFlow(): EventStreamRequestFlow { - return (this._eventStreamRequestFlow ??= instrumentWithSteps( - new EventStreamRequestFlow(this, this.pageManager), - )); + return (this._eventStreamRequestFlow ??= new EventStreamRequestFlow(this, this.pageManager)); } get exportFlow(): ExportFlow { - return (this._exportFlow ??= instrumentWithSteps( - new ExportFlow(this, this.pageManager), - )); + return (this._exportFlow ??= new ExportFlow(this, this.pageManager)); } get folderFlow(): FolderFlow { - return (this._folderFlow ??= instrumentWithSteps( - new FolderFlow(this, this.pageManager), - )); + return (this._folderFlow ??= new FolderFlow(this, this.pageManager)); } get gitSyncFlow(): GitSyncFlow { - return (this._gitSyncFlow ??= instrumentWithSteps( - new GitSyncFlow(this, this.pageManager), - )); + return (this._gitSyncFlow ??= new GitSyncFlow(this, this.pageManager)); } get graphqlRequestFlow(): GraphQLRequestFlow { - return (this._graphqlRequestFlow ??= instrumentWithSteps( - new GraphQLRequestFlow(this, this.pageManager), - )); + return (this._graphqlRequestFlow ??= new GraphQLRequestFlow(this, this.pageManager)); } get grpcRequestFlow(): GrpcRequestFlow { - return (this._grpcRequestFlow ??= instrumentWithSteps( - new GrpcRequestFlow(this, this.pageManager), - )); + return (this._grpcRequestFlow ??= new GrpcRequestFlow(this, this.pageManager)); } get httpRequestFlow(): HttpRequestFlow { - return (this._httpRequestFlow ??= instrumentWithSteps( - new HttpRequestFlow(this, this.pageManager), - )); + return (this._httpRequestFlow ??= new HttpRequestFlow(this, this.pageManager)); } get importFlow(): ImportFlow { - return (this._importFlow ??= instrumentWithSteps( - new ImportFlow(this, this.pageManager), - )); + return (this._importFlow ??= new ImportFlow(this, this.pageManager)); } get mcpClientFlow(): McpClientFlow { - return (this._mcpClientFlow ??= instrumentWithSteps( - new McpClientFlow(this, this.pageManager, this.workspaceFlow), - )); + return (this._mcpClientFlow ??= new McpClientFlow(this, this.pageManager, this.workspaceFlow)); } get mockServerFlow(): MockServerFlow { - return (this._mockServerFlow ??= instrumentWithSteps( - new MockServerFlow(this, this.pageManager), - )); + return (this._mockServerFlow ??= new MockServerFlow(this, this.pageManager)); } get organizationFlow(): OrganizationFlow { - return (this._organizationFlow ??= instrumentWithSteps( - new OrganizationFlow(this, this.pageManager), - )); + return (this._organizationFlow ??= new OrganizationFlow(this, this.pageManager)); } get preferencesFlow(): PreferencesFlow { - return (this._preferencesFlow ??= instrumentWithSteps( - new PreferencesFlow(this, this.pageManager), - )); + return (this._preferencesFlow ??= new PreferencesFlow(this, this.pageManager)); } get socketIoRequestFlow(): SocketIORequestFlow { - return (this._socketIoRequestFlow ??= instrumentWithSteps( - new SocketIORequestFlow(this, this.pageManager), - )); + return (this._socketIoRequestFlow ??= new SocketIORequestFlow(this, this.pageManager)); } get templateTagFlow(): TemplateTagFlow { - return (this._templateTagFlow ??= instrumentWithSteps( - new TemplateTagFlow(this, this.pageManager), - )); + return (this._templateTagFlow ??= new TemplateTagFlow(this, this.pageManager)); } get variableSourceFlow(): VariableSourceFlow { - return (this._variableSourceFlow ??= instrumentWithSteps( - new VariableSourceFlow(this, this.pageManager), - )); + return (this._variableSourceFlow ??= new VariableSourceFlow(this, this.pageManager)); } get webSocketRequestFlow(): WebSocketRequestFlow { - return (this._webSocketRequestFlow ??= instrumentWithSteps( - new WebSocketRequestFlow(this, this.pageManager), - )); + return (this._webSocketRequestFlow ??= new WebSocketRequestFlow(this, this.pageManager)); } get workspaceFlow(): WorkspaceFlow { - return (this._workspaceFlow ??= instrumentWithSteps( - new WorkspaceFlow( + return (this._workspaceFlow ??= new WorkspaceFlow( this, this.pageManager, this.preferencesFlow, this, this.gitRepoUrl, - ), - )); + )); } } diff --git a/packages/insomnia-test/flows/folder.flow.ts b/packages/insomnia-test/flows/folder.flow.ts index e980eba6c0..8fd05604b1 100644 --- a/packages/insomnia-test/flows/folder.flow.ts +++ b/packages/insomnia-test/flows/folder.flow.ts @@ -1,5 +1,6 @@ import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import { Folder } from "../models/folder"; import type { OAuth2Tokens } from "../pages/auth-tab.page"; @@ -14,6 +15,7 @@ export class FolderFlow extends BaseFlow { * request (see `HttpRequestFlow.create()`), just with a different * context-menu item and a plain naming dialog instead of a rename step. */ + @step() async create(parent: Collection | Folder, folder: Folder): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); @@ -44,6 +46,7 @@ export class FolderFlow extends BaseFlow { * Docs) via its "Open in New Tab" context-menu item — not the same as * its "Settings" item, which only opens a rename/move dialog. */ + @step() async open(folder: Folder): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.findItemNode( @@ -65,6 +68,7 @@ export class FolderFlow extends BaseFlow { * token exists, "Refresh Token" once one does), then reads back the * resulting Refresh/Identity/Access Token fields in one call. */ + @step() async fetchOAuth2Tokens(folder: Folder): Promise { const folderPage = await this.open(folder); await folderPage.auth.fetchOAuth2Tokens(); @@ -76,6 +80,7 @@ export class FolderFlow extends BaseFlow { * scripts (see `ScriptTabComponent.setScripts()`) — these run for * every descendant request before/after that request's own script. */ + @step() async setScripts(folder: Folder, scripts: RequestScripts): Promise { const folderPage = await this.open(folder); await folderPage.scripts.setScripts(scripts); @@ -86,6 +91,7 @@ export class FolderFlow extends BaseFlow { * override the collection's base and sub-environment values for every * descendant request. */ + @step() async setEnvironmentVariables(folder: Folder, variables: Record): Promise { const folderPage = await this.open(folder); await folderPage.setEnvironmentVariables(variables); diff --git a/packages/insomnia-test/flows/git-sync.flow.ts b/packages/insomnia-test/flows/git-sync.flow.ts index 58ce4d3968..e69b2b58f1 100644 --- a/packages/insomnia-test/flows/git-sync.flow.ts +++ b/packages/insomnia-test/flows/git-sync.flow.ts @@ -1,5 +1,6 @@ import { GitChangeList } from "../enums/git-change-list"; import { GitDiffView } from "../enums/git-diff-view"; +import { step } from "../misc/step"; import { Commit } from "../models/commit"; import { BaseFlow } from "./base.flow"; @@ -7,6 +8,7 @@ export class GitSyncFlow extends BaseFlow { /** * Checks out `branch` directly from the Git Sync dropdown's branch list. */ + @step() async switchBranch(branch: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.switchBranch(branch); @@ -16,6 +18,7 @@ export class GitSyncFlow extends BaseFlow { * Stages all changes, commits with `message` (no push), and reads back * the resulting commit's id from History. */ + @step() async commit(message: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -37,6 +40,7 @@ export class GitSyncFlow extends BaseFlow { * Stages all changes, commits with `message`, then pushes, and reads * back the resulting commit's id from History. */ + @step() async commitAndPush(message: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -58,6 +62,7 @@ export class GitSyncFlow extends BaseFlow { * Reads the file names currently listed as staged and unstaged in the * Commit modal, then closes it. */ + @step() async getChanges(): Promise<{ staged: string[]; unstaged: string[] }> { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -71,6 +76,7 @@ export class GitSyncFlow extends BaseFlow { * Stages each of `fileNames` individually from the Commit modal, then * closes it. */ + @step() async stageChanges(fileNames: string[]): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -84,6 +90,7 @@ export class GitSyncFlow extends BaseFlow { * Unstages each of `fileNames` individually from the Commit modal, then * closes it. */ + @step() async unstageChanges(fileNames: string[]): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -98,6 +105,7 @@ export class GitSyncFlow extends BaseFlow { * closes the Commit modal if it's still open, and reads the resulting * commit back from History. */ + @step() async commitStaged(message: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -122,6 +130,7 @@ export class GitSyncFlow extends BaseFlow { /** * Discards every uncommitted local change. */ + @step() async discardAllChanges(): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -132,6 +141,7 @@ export class GitSyncFlow extends BaseFlow { * Creates `branch` from the current branch, which switches to it * immediately. */ + @step() async createBranch(branch: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -143,6 +153,7 @@ export class GitSyncFlow extends BaseFlow { * Attempts to create `branch` from the Branches modal and reads back the * validation error the app shows instead, then closes the modal. */ + @step() async createBranchExpectingError(branch: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -156,6 +167,7 @@ export class GitSyncFlow extends BaseFlow { * Reads the local and remote-only branch lists from the Branches modal, * then closes it. */ + @step() async getBranches(): Promise<{ local: string[]; remote: string[] }> { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -169,6 +181,7 @@ export class GitSyncFlow extends BaseFlow { * Fetches `branch` (a branch that exists only on the remote) and checks * it out via the Branches modal. */ + @step() async checkoutRemoteBranch(branch: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -179,6 +192,7 @@ export class GitSyncFlow extends BaseFlow { /** * Fetches from the remote via the Git Sync dropdown. */ + @step() async fetch(): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.fetch(); @@ -187,6 +201,7 @@ export class GitSyncFlow extends BaseFlow { /** * Pulls the current branch from the remote via the Git Sync dropdown. */ + @step() async pull(): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.pull(); @@ -196,6 +211,7 @@ export class GitSyncFlow extends BaseFlow { * Pushes the current branch's commits to the remote via the Git Sync * dropdown's standalone "Push" action. */ + @step() async push(): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.push(); @@ -204,6 +220,7 @@ export class GitSyncFlow extends BaseFlow { /** * Merges `branch` into whichever branch is currently checked out. */ + @step() async mergeBranch(branch: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -215,6 +232,7 @@ export class GitSyncFlow extends BaseFlow { * Merges `branch` into the current branch, expecting conflicts, and * resolves all of them by taking the same side. */ + @step() async mergeBranchResolvingConflicts( branch: string, version: "Current" | "Incoming", @@ -230,6 +248,7 @@ export class GitSyncFlow extends BaseFlow { * Checks out `switchToBranch` (since a branch can't be deleted while * checked out) and deletes `branch`. */ + @step() async deleteBranch(branch: string, switchToBranch = "master"): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openBranchesDialog(); @@ -243,6 +262,7 @@ export class GitSyncFlow extends BaseFlow { * clicking Commit, continues past the "Unrecognized Fields" confirmation * before reading the resulting commit back from History. */ + @step() async commitStagedConfirmingUnknownFields(message: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -270,6 +290,7 @@ export class GitSyncFlow extends BaseFlow { * waiting for the file to leave the unstaged list (so it also works for a * change that comes straight back), then closes the modal. */ + @step() async discardChangeAfterReview(fileName: string): Promise { const { gitSyncPage } = this.pageManager; await gitSyncPage.openCommitDialog(); @@ -283,6 +304,7 @@ export class GitSyncFlow extends BaseFlow { * Opens the Commit modal (if it isn't already open), selects a file in * the given list and switches its diff pane to the Visual (Beta) view. */ + @step() async openVisualDiff(list: GitChangeList, fileName: string): Promise { const { gitSyncPage } = this.pageManager; if (!(await gitSyncPage.isCommitDialogOpen())) { @@ -295,6 +317,7 @@ export class GitSyncFlow extends BaseFlow { /** * Stages one entity from its card in the Visual view of an unstaged file. */ + @step() async stageEntity(fileName: string, entityName: string, typeLabel?: string): Promise { const { gitSyncPage } = this.pageManager; await this.openVisualDiff(GitChangeList.Unstaged, fileName); @@ -304,6 +327,7 @@ export class GitSyncFlow extends BaseFlow { /** * Unstages one entity from its card in the Visual view of a staged file. */ + @step() async unstageEntity(fileName: string, entityName: string, typeLabel?: string): Promise { const { gitSyncPage } = this.pageManager; await this.openVisualDiff(GitChangeList.Staged, fileName); @@ -314,6 +338,7 @@ export class GitSyncFlow extends BaseFlow { * Discards one entity from its card in the Visual view of an unstaged * file, confirming the prompt. */ + @step() async discardEntity(fileName: string, entityName: string, typeLabel?: string): Promise { const { gitSyncPage } = this.pageManager; await this.openVisualDiff(GitChangeList.Unstaged, fileName); diff --git a/packages/insomnia-test/flows/graphql-request.flow.ts b/packages/insomnia-test/flows/graphql-request.flow.ts index 44883df5d4..08231d16c8 100644 --- a/packages/insomnia-test/flows/graphql-request.flow.ts +++ b/packages/insomnia-test/flows/graphql-request.flow.ts @@ -2,6 +2,7 @@ import type { RequestAuthentication } from "insomnia-data"; import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Folder } from "../models/folder"; import { GraphQLRequest } from "../models/graphql-request"; @@ -15,6 +16,7 @@ export class GraphQLRequestFlow extends BaseFlow { * Creates a GraphQL request under `parent` via the standalone project * tree, then verifies it was created. */ + @step() async create( parent: Collection | Folder, request: GraphQLRequest, @@ -41,6 +43,7 @@ export class GraphQLRequestFlow extends BaseFlow { * Reads back an existing GraphQL request's fields via the standalone * project tree. */ + @step() async get( item: string | { name: string; id?: string }, parent?: Collection, @@ -63,6 +66,7 @@ export class GraphQLRequestFlow extends BaseFlow { * Opens `request`, clicks its Auth tab's token-fetch button and reads * the resulting tokens back. */ + @step() async fetchOAuth2Tokens(request: GraphQLRequest): Promise { const workspace = this.pageManager.workspacePage; const page = this.pageManager.graphqlRequestPage; @@ -78,6 +82,7 @@ export class GraphQLRequestFlow extends BaseFlow { /** * Opens `request` and applies `authentication` on its Auth tab. */ + @step() async setAuthentication( request: GraphQLRequest, authentication: RequestAuthentication, @@ -95,6 +100,7 @@ export class GraphQLRequestFlow extends BaseFlow { * Sends `request` and reads back the resulting response via the * standalone project tree. */ + @step() async send(request: GraphQLRequest): Promise { const workspace = this.pageManager.workspacePage; const graphqlRequestPage = this.pageManager.graphqlRequestPage; diff --git a/packages/insomnia-test/flows/grpc-request.flow.ts b/packages/insomnia-test/flows/grpc-request.flow.ts index 298a18a39f..6e77c71bf9 100644 --- a/packages/insomnia-test/flows/grpc-request.flow.ts +++ b/packages/insomnia-test/flows/grpc-request.flow.ts @@ -1,5 +1,6 @@ import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Folder } from "../models/folder"; import { GrpcRequest } from "../models/grpc-request"; @@ -17,6 +18,7 @@ export class GrpcRequestFlow extends BaseFlow { * Creates a gRPC request under `parent` via the standalone project tree, * then verifies it was created. */ + @step() async create(parent: Collection | Folder, request: GrpcRequest): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); @@ -40,6 +42,7 @@ export class GrpcRequestFlow extends BaseFlow { * Reads back an existing gRPC request's fields via the standalone * project tree. */ + @step() async get( item: string | { name: string; id?: string }, parent?: Collection, @@ -63,6 +66,7 @@ export class GrpcRequestFlow extends BaseFlow { * server-streaming call, or an open client/bidi-streaming call — then * reads back the resulting gRPC status. */ + @step() async cancel(request: GrpcRequest): Promise { const grpcRequestPage = await this.openRequest(request); await grpcRequestPage.cancel(); @@ -74,6 +78,7 @@ export class GrpcRequestFlow extends BaseFlow { * bidi-streaming call by clicking "Commit", then reads back the * resulting gRPC status and message(s). */ + @step() async commit(request: GrpcRequest): Promise { const grpcRequestPage = await this.openRequest(request); await grpcRequestPage.commit(); @@ -88,6 +93,7 @@ export class GrpcRequestFlow extends BaseFlow { * response arrives until `commit()` closes the client's half of the * stream. */ + @step() async send(request: GrpcRequest): Promise { const grpcRequestPage = await this.openRequest(request); await grpcRequestPage.send(); @@ -99,6 +105,7 @@ export class GrpcRequestFlow extends BaseFlow { * Unlike `send()`, this does not wait for a response — none arrives * until `commit()` closes the client's half of the stream. */ + @step() async start(request: GrpcRequest): Promise { const grpcRequestPage = await this.openRequest(request); await grpcRequestPage.send(); @@ -109,6 +116,7 @@ export class GrpcRequestFlow extends BaseFlow { * on disk and in the request pane's loader data, so a following * `send()` can't go out with the previous body. */ + @step() async setBody(request: GrpcRequest, body: string): Promise { if (!request.id) { throw new Error( @@ -126,6 +134,7 @@ export class GrpcRequestFlow extends BaseFlow { * bidi-streaming call. Can be called repeatedly with different bodies * before calling `commit()`. */ + @step() async streamMessage(request: GrpcRequest, body: string): Promise { if (!request.id) { throw new Error( diff --git a/packages/insomnia-test/flows/http-request.flow.ts b/packages/insomnia-test/flows/http-request.flow.ts index 1387c770f7..1dac3d7618 100644 --- a/packages/insomnia-test/flows/http-request.flow.ts +++ b/packages/insomnia-test/flows/http-request.flow.ts @@ -1,6 +1,7 @@ import { ContextMenuItem } from "../enums/context-menu-items"; import type { ResponseExportOption } from "../enums/response-export-option"; import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Folder } from "../models/folder"; import type { RequestAuthentication, RequestParameter } from "../models/http-request"; @@ -15,6 +16,7 @@ export class HttpRequestFlow extends BaseFlow { * Creates a new HTTP request under `parent`, naming it via the rename * flow and applying its fields on the request page. */ + @step() async create(parent: Collection | Folder, request: HttpRequest): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); @@ -39,6 +41,7 @@ export class HttpRequestFlow extends BaseFlow { * editor. Use after `create()` for requests with many params, where * per-row editing is prone to a stale-row-list save race. */ + @step() async setParamsBulk(request: HttpRequest, params: RequestParameter[]): Promise { const workspace = this.pageManager.workspacePage; const httpRequestPage = this.pageManager.httpRequestPage; @@ -53,6 +56,7 @@ export class HttpRequestFlow extends BaseFlow { * Reads back an existing request's fields by name or identity, looking * it up in the standalone project tree. */ + @step() async get( item: string | { name: string; id?: string }, ): Promise { @@ -74,6 +78,7 @@ export class HttpRequestFlow extends BaseFlow { * Sends `request` and returns the resulting response, locating it in the * standalone project tree. */ + @step() async send(request: HttpRequest): Promise { const workspace = this.pageManager.workspacePage; const httpRequestPage = this.pageManager.httpRequestPage; @@ -96,6 +101,7 @@ export class HttpRequestFlow extends BaseFlow { * any token exists, "Refresh Token" once one does), then reads back * the resulting Refresh/Identity/Access Token fields in one call. */ + @step() async fetchOAuth2Tokens(request: HttpRequest): Promise { const workspace = this.pageManager.workspacePage; const httpRequestPage = this.pageManager.httpRequestPage; @@ -112,6 +118,7 @@ export class HttpRequestFlow extends BaseFlow { * Exports the response currently shown for the last-sent request to * `filePath` via the response pane's "Preview" dropdown. */ + @step() async exportResponse(option: ResponseExportOption, filePath: string): Promise { await this.pageManager.responsePage.exportResponse(option, filePath); } @@ -120,6 +127,7 @@ export class HttpRequestFlow extends BaseFlow { * Saves the large response currently shown for the last-sent request to * `filePath` via the response pane's "Save To File" button. */ + @step() async saveResponseToFile(filePath: string): Promise { await this.pageManager.responsePage.saveResponseToFile(filePath); } @@ -128,6 +136,7 @@ export class HttpRequestFlow extends BaseFlow { * Reads an export menu item's label and disabled state for the response * currently shown. */ + @step() async getResponseExportOptionState( option: ResponseExportOption, ): Promise<{ text: string; disabled: boolean }> { diff --git a/packages/insomnia-test/flows/import.flow.ts b/packages/insomnia-test/flows/import.flow.ts index 0e2b99bb54..cd16bf7157 100644 --- a/packages/insomnia-test/flows/import.flow.ts +++ b/packages/insomnia-test/flows/import.flow.ts @@ -1,5 +1,6 @@ import { ContextMenuItem } from "../enums/context-menu-items"; import { ImportSource } from "../enums/import-sources"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { CurlCommand } from "../models/curl-command"; import type { Project } from "../models/project"; @@ -10,6 +11,7 @@ export class ImportFlow extends BaseFlow { * Opens the Import dialog from `project`'s context menu, scans clipboard * content pasted into the import source, then imports it into the project. */ + @step() async importClipboard(project: Project, content: string): Promise { const workspace = this.pageManager.workspacePage; const projectNode = (await workspace.getTree()) @@ -32,6 +34,7 @@ export class ImportFlow extends BaseFlow { * Opens the Import dialog from `project`'s context menu, scans a raw curl * command, then imports it into the given collection within that project. */ + @step() async importCurl( project: Project, collection: Collection, @@ -60,6 +63,7 @@ export class ImportFlow extends BaseFlow { * Opens the Import dialog from `project`'s context menu, scans a local * file, then imports it into the project. */ + @step() async importFile(project: Project, filePath: string): Promise { const workspace = this.pageManager.workspacePage; const projectNode = (await workspace.getTree()) @@ -82,6 +86,7 @@ export class ImportFlow extends BaseFlow { * Opens the Import dialog from `project`'s context menu, scans an MCP * server URL, then imports it into the project. */ + @step() async importMcp(project: Project, url: string): Promise { const workspace = this.pageManager.workspacePage; const projectNode = (await workspace.getTree()) @@ -104,6 +109,7 @@ export class ImportFlow extends BaseFlow { * Opens the Import dialog from `project`'s context menu, scans content * fetched from a URL, then imports it into the project. */ + @step() async importUrl(project: Project, url: string): Promise { const workspace = this.pageManager.workspacePage; const projectNode = (await workspace.getTree()) diff --git a/packages/insomnia-test/flows/mcp-client.flow.ts b/packages/insomnia-test/flows/mcp-client.flow.ts index bd223b254b..2b61286480 100644 --- a/packages/insomnia-test/flows/mcp-client.flow.ts +++ b/packages/insomnia-test/flows/mcp-client.flow.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import { type McpCapabilityType, McpListAction } from "../enums/mcp-capability"; import { DEFAULT_TIMEOUT } from "../misc/constants"; +import { step } from "../misc/step"; import { McpClient } from "../models/mcp-client"; import type { Project } from "../models/project"; import type { ResponseView } from "../models/response"; @@ -27,6 +28,7 @@ export class McpClientFlow extends BaseFlow { * triggering/awaiting side effects the tool call depends on) before * reading the resulting response. */ + @step() async callTool( client: McpClient, tool: string, @@ -61,6 +63,7 @@ export class McpClientFlow extends BaseFlow { * Opens the given MCP Client node and clicks its disconnect action, * waiting for the connection to close. */ + @step() async disconnect(client: McpClient): Promise { const workspace = this.pageManager.workspacePage; const mcpClientPage = this.pageManager.mcpClientPage; @@ -75,6 +78,7 @@ export class McpClientFlow extends BaseFlow { * Opens `client`, clicks its Auth tab's token-fetch button and reads the * resulting tokens back. */ + @step() async fetchOAuth2Tokens(client: McpClient): Promise { const workspace = this.pageManager.workspacePage; const mcpClientPage = this.pageManager.mcpClientPage; @@ -90,6 +94,7 @@ export class McpClientFlow extends BaseFlow { /** * Opens the given MCP Client node and clicks its connect action. */ + @step() async connect( client: McpClient, options?: { expectConnected?: boolean }, @@ -107,6 +112,7 @@ export class McpClientFlow extends BaseFlow { * Creates an MCP Client node under `parent` (delegating the tree creation * to `workspaceFlow.createMcpClient`), then opens it and sets its URL. */ + @step() async create(parent: Project, client: McpClient): Promise { await this.workspaceFlow.createMcpClient(parent, client); @@ -139,6 +145,7 @@ export class McpClientFlow extends BaseFlow { * Finds the MCP Client node matching `item` in the workspace tree, opens * it, and reads back its configured URL. */ + @step() async get( item: string | { name: string; id?: string }, ): Promise { @@ -161,6 +168,7 @@ export class McpClientFlow extends BaseFlow { /** * Opens the given MCP Client node and lists the names of its available tools. */ + @step() async getTools(client: McpClient): Promise { const workspace = this.pageManager.workspacePage; const mcpClientPage = this.pageManager.mcpClientPage; @@ -175,6 +183,7 @@ export class McpClientFlow extends BaseFlow { * Opens the given MCP Client node and reads the error panel of one of its * sidebar capability items, waiting for it to appear. */ + @step() async getListError(client: McpClient, type: McpCapabilityType): Promise { await this.open(client); return this.pageManager.mcpClientPage.getCapabilityError(type); @@ -184,6 +193,7 @@ export class McpClientFlow extends BaseFlow { * Opens the given MCP Client node and collapses or expands one of its * sidebar capability items. */ + @step() async toggleCapability(client: McpClient, type: McpCapabilityType): Promise { await this.open(client); await this.pageManager.mcpClientPage.toggleCapability(type); @@ -193,6 +203,7 @@ export class McpClientFlow extends BaseFlow { * Opens the given MCP Client node and re-runs one capability item's list * request via its actions menu, re-deriving its entries and error. */ + @step() async refresh(client: McpClient, type: McpCapabilityType): Promise { await this.open(client); await this.pageManager.mcpClientPage.clickCapabilityAction(type, McpListAction.Reload); @@ -203,6 +214,7 @@ export class McpClientFlow extends BaseFlow { * item's list via its actions menu (offered once the server returned a * `nextCursor`). */ + @step() async loadMore(client: McpClient, type: McpCapabilityType): Promise { await this.open(client); await this.pageManager.mcpClientPage.clickCapabilityAction(type, McpListAction.LoadMore); @@ -212,6 +224,7 @@ export class McpClientFlow extends BaseFlow { * Opens the given MCP Client node and reads which list action one of its * capability items currently offers. */ + @step() async getListAction(client: McpClient, type: McpCapabilityType): Promise { await this.open(client); return this.pageManager.mcpClientPage.getCapabilityActionName(type); @@ -220,6 +233,7 @@ export class McpClientFlow extends BaseFlow { /** * Opens the given MCP Client node and filters its sidebar primitives by name. */ + @step() async filterCapabilities(client: McpClient, text: string): Promise { await this.open(client); await this.pageManager.mcpClientPage.setCapabilityFilter(text); diff --git a/packages/insomnia-test/flows/mock-server.flow.ts b/packages/insomnia-test/flows/mock-server.flow.ts index 987b2a034c..4c73dfb9f3 100644 --- a/packages/insomnia-test/flows/mock-server.flow.ts +++ b/packages/insomnia-test/flows/mock-server.flow.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import { ContextMenuItem } from "../enums/context-menu-items"; import { DEFAULT_TIMEOUT } from "../misc/constants"; +import { step } from "../misc/step"; import type { MockRoute, MockRouteHistoryEntry } from "../models/mock-route"; import { MockServer } from "../models/mock-server"; import type { Project } from "../models/project"; @@ -13,6 +14,7 @@ export class MockServerFlow extends BaseFlow { * Creates a self-hosted Mock Server under `parent` through the project's * create menu and the create dialog, then re-reads it from the tree. */ + @step() async create(parent: Project, server: MockServer): Promise { const workspace = this.pageManager.workspacePage; const mockServerPage = this.pageManager.mockServerPage; @@ -30,6 +32,7 @@ export class MockServerFlow extends BaseFlow { /** * Finds a Mock Server's tree node by name/id and returns it as a model. */ + @step() async get(item: string | { name: string; id?: string }): Promise { const identity = typeof item === "string" ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); @@ -41,6 +44,7 @@ export class MockServerFlow extends BaseFlow { * Duplicates `server` through its context menu's "Duplicate / Move" * dialog. */ + @step() async duplicate( server: MockServer, newName: string, @@ -62,6 +66,7 @@ export class MockServerFlow extends BaseFlow { * Exports `server` (as an Insomnia v5 YAML file) through its context * menu's "Export" item, writing it to `path`. */ + @step() async export(server: MockServer, path: string): Promise { const workspace = this.pageManager.workspacePage; const exportPage = this.pageManager.exportPage; @@ -75,6 +80,7 @@ export class MockServerFlow extends BaseFlow { /** * Opens `server`'s Mock Server view. */ + @step() async open(server: MockServer): Promise { const workspace = this.pageManager.workspacePage; const mockServerPage = this.pageManager.mockServerPage; @@ -89,6 +95,7 @@ export class MockServerFlow extends BaseFlow { * then, if it carries any, configures its response (status code/text, * body type, body and headers). */ + @step() async createRoute(server: MockServer, route: MockRoute): Promise { const mockServerPage = this.pageManager.mockServerPage; @@ -105,6 +112,7 @@ export class MockServerFlow extends BaseFlow { * status code, status text, body type, body and headers — then moves * focus off the last edited editor so it pushes to the server. */ + @step() async setResponse(route: MockRoute): Promise { const mockServerPage = this.pageManager.mockServerPage; @@ -140,6 +148,7 @@ export class MockServerFlow extends BaseFlow { * Opens `route` and edits its path and/or method through its action * menu's "Edit" dialog. */ + @step() async editRoute( route: MockRoute, changes: Pick, @@ -153,6 +162,7 @@ export class MockServerFlow extends BaseFlow { /** * Deletes `route` through its action menu and the confirmation dialog. */ + @step() async deleteRoute(route: MockRoute): Promise { await this.pageManager.mockServerPage.deleteRoute(route.id!); } @@ -160,6 +170,7 @@ export class MockServerFlow extends BaseFlow { /** * Opens `server` and lists the paths of the routes in its sidebar. */ + @step() async getRoutePaths(server: MockServer): Promise { await this.open(server); return this.pageManager.mockServerPage.getRoutePaths(); @@ -168,6 +179,7 @@ export class MockServerFlow extends BaseFlow { /** * Opens `route` and reads the method and path its URL bar shows. */ + @step() async getRoute(route: MockRoute): Promise<{ method: string; path: string }> { const mockServerPage = this.pageManager.mockServerPage; @@ -179,6 +191,7 @@ export class MockServerFlow extends BaseFlow { * Opens `route` and reads its full URL on the mock server out of the * "Show URL" dialog. */ + @step() async getUrl(route: MockRoute): Promise { const mockServerPage = this.pageManager.mockServerPage; @@ -190,6 +203,7 @@ export class MockServerFlow extends BaseFlow { * Opens `route`, clicks "Test" and reads the response the mock server * answered with. */ + @step() async test(route: MockRoute): Promise { const mockServerPage = this.pageManager.mockServerPage; const responsePage = this.pageManager.responsePage; @@ -204,6 +218,7 @@ export class MockServerFlow extends BaseFlow { * On the response `request` currently shows, opens the "→ Mock" tab and * creates a new Mock Route on `server` from it. */ + @step() async createRouteFromResponse( server: MockServer, path?: string, @@ -222,6 +237,7 @@ export class MockServerFlow extends BaseFlow { * On the response the request currently shows, opens the "→ Mock" tab * and overwrites `route` with it. */ + @step() async overwriteRouteFromResponse( server: MockServer, route: MockRoute, @@ -236,6 +252,7 @@ export class MockServerFlow extends BaseFlow { /** * Opens `route` and reads the hits its History tab has logged. */ + @step() async getHistory(route: MockRoute): Promise { const mockServerPage = this.pageManager.mockServerPage; diff --git a/packages/insomnia-test/flows/organization.flow.ts b/packages/insomnia-test/flows/organization.flow.ts index a513ffe8d2..de651f1611 100644 --- a/packages/insomnia-test/flows/organization.flow.ts +++ b/packages/insomnia-test/flows/organization.flow.ts @@ -1,4 +1,5 @@ import type { CollaboratorRole } from "../enums/collaborator-role"; +import { step } from "../misc/step"; import { BaseFlow } from "./base.flow"; /** @@ -28,6 +29,7 @@ export class OrganizationFlow extends BaseFlow { * doesn't already match, rather than always clicking through the role * menu. */ + @step() async invite(...collaborators: Collaborator[]): Promise { const { invitePage } = this.pageManager; const alreadyListed = (c: Collaborator) => diff --git a/packages/insomnia-test/flows/preferences.flow.ts b/packages/insomnia-test/flows/preferences.flow.ts index 1d68f490f7..f86422dde3 100644 --- a/packages/insomnia-test/flows/preferences.flow.ts +++ b/packages/insomnia-test/flows/preferences.flow.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import type { ScriptSandboxRuleGroup } from "../enums/script-sandbox-rule-group"; import { DEFAULT_TIMEOUT } from "../misc/constants"; +import { step } from "../misc/step"; import type { GitCredential } from "../models/git-credential"; import type { AiUrlBackendSettings} from "../models/settings"; import { Settings } from "../models/settings"; @@ -14,6 +15,7 @@ export class PreferencesFlow extends BaseFlow { * closes it. Only settings that are defined on `settings` are applied * (e.g. `validateSSL` is skipped when undefined). */ + @step() async set(settings: Settings): Promise { const preferencesPage = this.pageManager.preferencesPage; let aiUrlBackendResult: AiUrlBackendSettings | undefined; @@ -167,6 +169,7 @@ export class PreferencesFlow extends BaseFlow { * `.rejects.toThrow(...)`. If accepted, the modal closes on its own and * this closes Preferences to finish. */ + @step() async createPlugin(name: string): Promise { const preferencesPage = this.pageManager.preferencesPage; await preferencesPage.open(); @@ -187,6 +190,7 @@ export class PreferencesFlow extends BaseFlow { * Opens Preferences -> Scripting and toggles a single rule group's * sandbox switch, then closes Preferences. */ + @step() async toggleScriptSandboxRule( group: ScriptSandboxRuleGroup, enabled: boolean, @@ -203,6 +207,7 @@ export class PreferencesFlow extends BaseFlow { * Credentials, so it's available to select when cloning/connecting a Git * Sync project. */ + @step() async addGitCredential(credential: GitCredential): Promise { const { preferencesPage } = this.pageManager; await preferencesPage.open(); @@ -217,6 +222,7 @@ export class PreferencesFlow extends BaseFlow { * Preferences. Does NOT include bundled plugins (see * `PreferencesPage.getBundlePlugins()`), which never appear on this tab. */ + @step() async getInstalledPlugins(): Promise { const { preferencesPage } = this.pageManager; await preferencesPage.open(); @@ -232,6 +238,7 @@ export class PreferencesFlow extends BaseFlow { * salt exists yet for this session (a fresh session, or one whose vault * state was just reset — see `resetVaultKey()`). */ + @step() async generateVaultKey(): Promise { const { preferencesPage } = this.pageManager; await preferencesPage.open(); @@ -249,6 +256,7 @@ export class PreferencesFlow extends BaseFlow { * there's no dialog to surface via `.rejects.toThrow()`); returns `null` * on success. */ + @step() async enterVaultKey(key: string): Promise { const { preferencesPage } = this.pageManager; await preferencesPage.open(); @@ -267,6 +275,7 @@ export class PreferencesFlow extends BaseFlow { * Preferences. Requires a vault salt to already exist for this session * (e.g. via the `vaultSalt` fixture override). */ + @step() async resetVaultKey(): Promise { const { preferencesPage } = this.pageManager; await preferencesPage.open(); diff --git a/packages/insomnia-test/flows/socket-io-request.flow.ts b/packages/insomnia-test/flows/socket-io-request.flow.ts index 6a83dad83c..409b61f279 100644 --- a/packages/insomnia-test/flows/socket-io-request.flow.ts +++ b/packages/insomnia-test/flows/socket-io-request.flow.ts @@ -1,6 +1,7 @@ import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; import { DEFAULT_TIMEOUT } from "../misc/constants"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Folder } from "../models/folder"; import type { ResponseView } from "../models/response"; @@ -13,6 +14,7 @@ export class SocketIORequestFlow extends BaseFlow { * Opens the Socket.IO connection for `request`, locating it via the * standalone project tree before connecting. */ + @step() async connect(request: SocketIORequest): Promise { const workspace = this.pageManager.workspacePage; const socketIoRequestPage = this.pageManager.socketIoRequestPage; @@ -27,6 +29,7 @@ export class SocketIORequestFlow extends BaseFlow { * Creates a new Socket.IO request under `parent`, naming it via the * rename flow and applying its fields on the request page. */ + @step() async create( parent: Collection | Folder, request: SocketIORequest, @@ -55,6 +58,7 @@ export class SocketIORequestFlow extends BaseFlow { * returns the resulting response, locating it via the standalone project * tree. */ + @step() async disconnect( request: SocketIORequest, callback: () => Promise | void = () => {}, @@ -79,6 +83,7 @@ export class SocketIORequestFlow extends BaseFlow { * Reads back an existing request's fields by name or identity via the * standalone project tree. */ + @step() async get( item: string | { name: string; id?: string }, parent?: Collection, @@ -102,6 +107,7 @@ export class SocketIORequestFlow extends BaseFlow { * connection and returns the resulting response, locating it via the * standalone project tree. */ + @step() async sendMessage(request: SocketIORequest): Promise { const workspace = this.pageManager.workspacePage; const socketIoRequestPage = this.pageManager.socketIoRequestPage; diff --git a/packages/insomnia-test/flows/template-tag.flow.ts b/packages/insomnia-test/flows/template-tag.flow.ts index b767fd8b4a..414d76f104 100644 --- a/packages/insomnia-test/flows/template-tag.flow.ts +++ b/packages/insomnia-test/flows/template-tag.flow.ts @@ -1,4 +1,5 @@ import { TreeNodeType } from "../enums/tree-node-types"; +import { step } from "../misc/step"; import type { HttpRequest } from "../models/http-request"; import type { ResponseView } from "../models/response"; import type { TemplateTag, TemplateTagName } from "../models/template-tag"; @@ -16,6 +17,7 @@ export class TemplateTagFlow extends BaseFlow { * arguments too, since those have dedicated getters), and confirms via * "Done". */ + @step() async get(template: string): Promise { const templateTag = this.pageManager.templateTagPage; const tag = template.match(/\{%.*?%\}/)?.[0] ?? template; @@ -47,6 +49,7 @@ export class TemplateTagFlow extends BaseFlow { * `PreferencesFlow.addCloudCredentials()`). No other tag is supported * today, since these are the only two with dedicated argument setters. */ + @step() async edit(template: string, target: TemplateTag): Promise { const templateTag = this.pageManager.templateTagPage; const tag = template.match(/\{%.*?%\}/)?.[0] ?? template; @@ -80,6 +83,7 @@ export class TemplateTagFlow extends BaseFlow { * clicks "Clear" to wipe the value cached under its storage key, and * confirms via "Done". */ + @step() async clearCachedPrompt(template: string): Promise { const templateTag = this.pageManager.templateTagPage; const tag = template.match(/\{%.*?%\}/)?.[0] ?? template; @@ -93,6 +97,7 @@ export class TemplateTagFlow extends BaseFlow { * value), answers the resulting prompt dialog, and reads back the * response. */ + @step() async sendAnsweringPrompt( request: HttpRequest, label: string, diff --git a/packages/insomnia-test/flows/variable-source.flow.ts b/packages/insomnia-test/flows/variable-source.flow.ts index dc2f32588c..8f1582734e 100644 --- a/packages/insomnia-test/flows/variable-source.flow.ts +++ b/packages/insomnia-test/flows/variable-source.flow.ts @@ -1,3 +1,4 @@ +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { VariableSourceModal, @@ -20,6 +21,7 @@ export class VariableSourceFlow extends BaseFlow { * tag matching `snippet` to open its editor modal, and reads its Source * row plus Live Preview once the preview settles on `expectedPreview`. */ + @step() async inspectVariableModal( snippet: string, expectedPreview: string, @@ -35,6 +37,7 @@ export class VariableSourceFlow extends BaseFlow { * environment page's editor to open its tag editor modal, and reads its * Source row plus Live Preview once the preview settles. */ + @step() async inspectEnvironmentPageVariable( snippet: string, expectedPreview: string, @@ -48,6 +51,7 @@ export class VariableSourceFlow extends BaseFlow { * Hovers the template tag matching `snippet` in the currently open * environment page's editor until its variable-source tooltip shows. */ + @step() async hoverEnvironmentPageVariable( snippet: string, ): Promise<{ tooltip: string; chip: string }> { @@ -59,6 +63,7 @@ export class VariableSourceFlow extends BaseFlow { * template tag matching `snippet` until its variable-source tooltip * shows. */ + @step() async hoverVariable( snippet: string, ): Promise<{ tooltip: string; chip: string }> { @@ -71,6 +76,7 @@ export class VariableSourceFlow extends BaseFlow { * Hovers the template tag matching `snippet` inside the open Manage * Environments modal until its variable-source tooltip shows. */ + @step() async hoverVariableInModal( snippet: string, scope: VariableSourceScope = "modal", @@ -82,6 +88,7 @@ export class VariableSourceFlow extends BaseFlow { * Hovers the tag matching `snippet` on the Body tab, then hovers the * tooltip's own content and reports whether it stayed visible. */ + @step() async hoverTooltipContent(snippet: string): Promise { const page = this.pageManager.variableSourcePage; await page.navigate(); @@ -94,6 +101,7 @@ export class VariableSourceFlow extends BaseFlow { * the tooltip text after each hover (the tooltip swaps rather than * vanishing when the pointer moves between tags). */ + @step() async hoverBetweenVariables( first: string, firstExpected: string, @@ -116,6 +124,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Switches the open tag editor modal's selected variable. */ + @step() async selectModalVariable( option: string | { label: string }, expectedPreview?: string, @@ -131,6 +140,7 @@ export class VariableSourceFlow extends BaseFlow { * Types a custom template into the open tag editor modal and reads the * re-derived Source row and Live Preview. */ + @step() async setModalTemplate( template: string, expectedPreview?: string, @@ -145,6 +155,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Clicks the open tag editor modal's Source "Open" button. */ + @step() async openSourceFromModal(): Promise { await this.pageManager.variableSourcePage.clickModalSourceOpen(); } @@ -152,6 +163,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Clicks the visible variable-source tooltip's "Open" button. */ + @step() async openSourceFromTooltip(): Promise { await this.pageManager.variableSourcePage.clickTooltipOpen(); } @@ -159,6 +171,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Opens `collection` and picks an imported project environment for it. */ + @step() async selectProjectEnvironment( collection: Collection, optionName: string, @@ -171,6 +184,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Opens the collection's Manage Environments modal. */ + @step() async openManageEnvironmentsModal(): Promise { await this.pageManager.variableSourcePage.openManageEnvironmentsModal(); } @@ -178,6 +192,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Selects an environment row inside the open Manage Environments modal. */ + @step() async selectModalEnvironment(name: string): Promise { await this.pageManager.variableSourcePage.selectModalEnvironment(name); } @@ -185,6 +200,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Reads where an "Open" action landed. */ + @step() async getNavigation( landing: VariableSourceLanding, environmentRow?: string, @@ -213,6 +229,7 @@ export class VariableSourceFlow extends BaseFlow { /** * Waits for the Manage Environments modal to close. */ + @step() async waitForManageEnvironmentsModalClosed(): Promise { await this.pageManager.variableSourcePage.waitForManageEnvironmentsModalHidden(); } diff --git a/packages/insomnia-test/flows/web-socket-request.flow.ts b/packages/insomnia-test/flows/web-socket-request.flow.ts index 1ffd1586df..29a23b4b7d 100644 --- a/packages/insomnia-test/flows/web-socket-request.flow.ts +++ b/packages/insomnia-test/flows/web-socket-request.flow.ts @@ -3,6 +3,7 @@ import type { RequestAuthentication } from "insomnia-data"; import { ContextMenuItem } from "../enums/context-menu-items"; import { TreeNodeType } from "../enums/tree-node-types"; import { DEFAULT_TIMEOUT } from "../misc/constants"; +import { step } from "../misc/step"; import type { Collection } from "../models/collection"; import type { Folder } from "../models/folder"; import type { ResponseView } from "../models/response"; @@ -19,6 +20,7 @@ export class WebSocketRequestFlow extends BaseFlow { * Creates a new WebSocket request under `parent`, naming it via the * rename flow and applying its fields on the request page. */ + @step() async create( parent: Collection | Folder, request: WebSocketRequest, @@ -51,6 +53,7 @@ export class WebSocketRequestFlow extends BaseFlow { * returns the resulting response, locating it via the standalone * project tree. */ + @step() async disconnect( request: WebSocketRequest, callback: () => Promise | void = () => {}, @@ -75,6 +78,7 @@ export class WebSocketRequestFlow extends BaseFlow { * Reads back an existing request's fields by name or identity via the * standalone project tree. */ + @step() async get( item: string | { name: string; id?: string }, parent?: Collection, @@ -97,6 +101,7 @@ export class WebSocketRequestFlow extends BaseFlow { * Opens the WebSocket connection for `request` and returns the * resulting response, locating it via the standalone project tree. */ + @step() async send( request: WebSocketRequest, options?: { expectConnected?: boolean }, @@ -119,6 +124,7 @@ export class WebSocketRequestFlow extends BaseFlow { /** * Opens `request` and applies `authentication` on its Auth tab. */ + @step() async setAuthentication( request: WebSocketRequest, authentication: RequestAuthentication, @@ -138,6 +144,7 @@ export class WebSocketRequestFlow extends BaseFlow { * `timeout` ms elapses), and returns the resulting response, locating it * via the standalone project tree. */ + @step() async sendMessage( request: WebSocketRequest, body: WebSocketRequestBody, diff --git a/packages/insomnia-test/flows/workspace.flow.ts b/packages/insomnia-test/flows/workspace.flow.ts index 466fde6163..071b4c0e18 100644 --- a/packages/insomnia-test/flows/workspace.flow.ts +++ b/packages/insomnia-test/flows/workspace.flow.ts @@ -5,6 +5,7 @@ import { LintSeverity } from '../enums/lint-severity'; import { ProjectType } from '../enums/project-types'; import { TreeNodeType } from '../enums/tree-node-types'; import { DEFAULT_TIMEOUT } from '../misc/constants'; +import { step } from "../misc/step"; import { Collection, TestSuite, UnitTest } from '../models/collection'; import { Environment } from '../models/environment'; import type { EventStreamRequest } from '../models/event-stream-request'; @@ -102,6 +103,7 @@ export class WorkspaceFlow extends BaseFlow { * already a repo) — instead of cloning from a remote URL: the "Open * local folder" mode of the create-project dialog's Git sub-form. */ + @step() async openLocalGitFolder(item: Project): Promise { if (item.folderPath === undefined) { throw new Error(`openLocalGitFolder(): Project "${item.name}" has no folderPath to open`); @@ -123,6 +125,7 @@ export class WorkspaceFlow extends BaseFlow { * Preferences -> Credentials) rather than relying on whichever * credential the form defaults to. */ + @step() async cloneGitProject( item: Project, credentialName: string, @@ -213,6 +216,7 @@ export class WorkspaceFlow extends BaseFlow { * naming dialog. Authors `collection.spec` into the spec editor * afterward, if given. */ + @step() async createInEmptyState(parent: Project, collection: Collection): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(parent); @@ -231,6 +235,7 @@ export class WorkspaceFlow extends BaseFlow { * an action decorated with `@throwOnDialog`) by clicking its "Ok" * button, so a test can continue with further steps afterward. */ + @step() async closeDialog(): Promise { await this.pageManager.workspacePage.closeDialog(); } @@ -239,6 +244,7 @@ export class WorkspaceFlow extends BaseFlow { * Reads whether the one-time "Welcome to focus mode" onboarding popover * is currently shown, then dismisses it if so. */ + @step() async dismissFocusModePrompt(): Promise { const { workspacePage } = this.pageManager; const shown = await workspacePage.isFocusModePromptShown(); @@ -252,6 +258,7 @@ export class WorkspaceFlow extends BaseFlow { * interaction via `pageManager.projectSettingsPage` (e.g. reading the * current git repo path, relocating it). */ + @step() async openSettings(project: Project): Promise { const { workspacePage, projectSettingsPage } = this.pageManager; const node = await workspacePage.resolveNode(project); @@ -265,6 +272,7 @@ export class WorkspaceFlow extends BaseFlow { * Project has no "Rename" context-menu item, so this is the only way * to rename one. */ + @step() async renameProject(project: Project, newName: string): Promise { const { projectSettingsPage } = this.pageManager; await this.openSettings(project); @@ -279,6 +287,7 @@ export class WorkspaceFlow extends BaseFlow { * Right-clicks the tree node matching `item` and deletes it via the * context menu. Does nothing if no matching node is found. */ + @step() async delete( item: | Project @@ -304,6 +313,7 @@ export class WorkspaceFlow extends BaseFlow { * Opens the command palette, searches for `query`, and reads back the * matching result names. */ + @step() async search(query: string): Promise { const { commandPalettePage } = this.pageManager; await commandPalettePage.open(); @@ -316,6 +326,7 @@ export class WorkspaceFlow extends BaseFlow { * generated code snippet via "Generate Code" — available for regular * requests, not gRPC. */ + @step() async generateCode( request: HttpRequest | GraphQLRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, options?: { target?: string; client?: string }, @@ -335,6 +346,7 @@ export class WorkspaceFlow extends BaseFlow { * optionally moving the copy into `targetProject`, then re-fetches it as * its full model. */ + @step() async duplicateCollection(item: Collection, newName: string, targetProject?: Project): Promise { await this.duplicateViaContextMenu(item, newName, targetProject); return this.assertCreated(await this.getCollection(newName), newName); @@ -345,6 +357,7 @@ export class WorkspaceFlow extends BaseFlow { * optionally moving the copy into `targetProject`, then re-fetches it as * its full model. */ + @step() async duplicateMcpClient(item: McpClient, newName: string, targetProject?: Project): Promise { await this.duplicateViaContextMenu(item, newName, targetProject); return this.assertCreated(await this.getMcpClient(newName), newName); @@ -355,6 +368,7 @@ export class WorkspaceFlow extends BaseFlow { * optionally moving the copy into `targetProject`, then re-fetches it as * its full model. */ + @step() async duplicateEnvironment(item: Environment, newName: string, targetProject?: Project): Promise { await this.duplicateViaContextMenu(item, newName, targetProject); @@ -374,6 +388,7 @@ export class WorkspaceFlow extends BaseFlow { /** * Duplicates a request in place via its dedicated "Duplicate" dialog. */ + @step() async duplicateRequest(item: RequestItem, newName: string): Promise<{ id: string; name: string }> { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(item); @@ -405,6 +420,7 @@ export class WorkspaceFlow extends BaseFlow { * Right-clicks the tree node matching `request` and pins it via the * context menu's "Pin" action. */ + @step() async pin( request: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, ): Promise { @@ -415,6 +431,7 @@ export class WorkspaceFlow extends BaseFlow { * Right-clicks the tree node matching `request` and unpins it via the * context menu's "Unpin" action. */ + @step() async unpin( request: HttpRequest | GraphQLRequest | GrpcRequest | EventStreamRequest | SocketIORequest | WebSocketRequest, ): Promise { @@ -438,6 +455,7 @@ export class WorkspaceFlow extends BaseFlow { * Renames a Collection via its context menu's "Rename" action, then * re-fetches it as its full model under the new name. */ + @step() async renameCollection(item: Collection, newName: string): Promise { await this.renameViaContextMenu(item, newName); return this.assertCreated(await this.getCollection(newName), newName); @@ -447,6 +465,7 @@ export class WorkspaceFlow extends BaseFlow { * Renames an McpClient via its context menu's "Rename" action, then * re-fetches it as its full model under the new name. */ + @step() async renameMcpClient(item: McpClient, newName: string): Promise { await this.renameViaContextMenu(item, newName); return this.assertCreated(await this.getMcpClient(newName), newName); @@ -456,6 +475,7 @@ export class WorkspaceFlow extends BaseFlow { * Renames an Environment via its context menu's "Rename" action, then * re-fetches it as its full model under the new name. */ + @step() async renameEnvironment(item: Environment, newName: string): Promise { await this.renameViaContextMenu(item, newName); return this.assertCreated(await this.getEnvironment(newName), newName); @@ -464,6 +484,7 @@ export class WorkspaceFlow extends BaseFlow { /** * Renames a request in place via its context menu's "Rename" action. */ + @step() async renameRequest(item: RequestItem, newName: string): Promise<{ id: string; name: string }> { await this.renameViaContextMenu(item, newName); const newNode = await this.pageManager.workspacePage.findNode(newName, TreeNodeType.Request); @@ -492,6 +513,7 @@ export class WorkspaceFlow extends BaseFlow { * with the Runner (e.g. Cancel/Skip) while it's in flight, rather than * waiting for it to finish. */ + @step() async openRunner(collection: Collection, options?: RunnerOptions): Promise { const workspace = this.pageManager.workspacePage; const runner = this.pageManager.runnerPage; @@ -517,6 +539,7 @@ export class WorkspaceFlow extends BaseFlow { * request before returning the collected test-result count and * per-iteration results. */ + @step() async run(collection: Collection, options?: RunnerOptions): Promise { const runner = this.pageManager.runnerPage; await this.openRunner(collection, options); @@ -580,6 +603,7 @@ export class WorkspaceFlow extends BaseFlow { * doesn't carry a spec; use `getCollectionSpec()` instead for one that * does (or might). */ + @step() async getCollection(item: string | { name: string; id?: string }): Promise { const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); @@ -596,6 +620,7 @@ export class WorkspaceFlow extends BaseFlow { * call `getLintState()` separately — it polls for the lint pass to * settle, which not every caller needs. */ + @step() async getCollectionSpec(item: string | { name: string; id?: string }): Promise { const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; @@ -622,6 +647,7 @@ export class WorkspaceFlow extends BaseFlow { * creation), this can be called at any later point — e.g. filling in a * collection that started out with no spec. */ + @step() async fillSpecification(item: Collection, spec: NonNullable): Promise { const workspace = this.pageManager.workspacePage; const node = await workspace.resolveNode(item); @@ -646,6 +672,7 @@ export class WorkspaceFlow extends BaseFlow { * renames it — the two nearly always happen together, since a fresh * suite is always named "New Suite". */ + @step() async createTestSuite(item: string | { name: string; id?: string }, name: string): Promise { const workspace = this.pageManager.workspacePage; await this.openTests(item); @@ -659,6 +686,7 @@ export class WorkspaceFlow extends BaseFlow { * the two nearly always happen together, since a fresh test is always * named "Returns 200". */ + @step() async createUnitTest(testSuite: TestSuite, testName: string): Promise { const workspace = this.pageManager.workspacePage; await this.openTests(testSuite.collection); @@ -672,6 +700,7 @@ export class WorkspaceFlow extends BaseFlow { * Opens `item`'s Tests tab, then deletes `suiteName`'s test suite via its * confirm modal (`WorkspacePage.deleteTestSuite()`). */ + @step() async deleteTestSuite(item: string | { name: string; id?: string }, suiteName: string): Promise { await this.openTests(item); await this.pageManager.workspacePage.deleteTestSuite(suiteName); @@ -688,6 +717,7 @@ export class WorkspaceFlow extends BaseFlow { * already on the collection's page after `create()`/`getCollectionSpec()`/ * `WorkspacePage.setSpecification()`. */ + @step() async getLintState(minLintErrors = 0): Promise> { const workspace = this.pageManager.workspacePage; @@ -729,6 +759,7 @@ export class WorkspaceFlow extends BaseFlow { * (`WorkspacePage.getUnitTestNames()`) and a reference back to `item`, * so the suite can be traced back to where it lives. */ + @step() async getTestSuite(item: string | { name: string; id?: string }, testSuiteName: string): Promise { const workspace = this.pageManager.workspacePage; const node = await this.openTests(item); @@ -746,6 +777,7 @@ export class WorkspaceFlow extends BaseFlow { * Opens `item`'s Tests tab and reads back every suite name currently * listed in the Test Suites sidebar (`WorkspacePage.getTestSuiteNames()`). */ + @step() async getTestSuiteNames(item: string | { name: string; id?: string }): Promise { await this.openTests(item); return this.pageManager.workspacePage.getTestSuiteNames(); @@ -756,6 +788,7 @@ export class WorkspaceFlow extends BaseFlow { * via its confirm modal (`WorkspacePage.removeRuleset()`), reverting to * the default OAS ruleset. */ + @step() async removeRuleset(item: string | { name: string; id?: string }): Promise { const identity = typeof item === 'string' ? { name: item } : item; const workspace = this.pageManager.workspacePage; @@ -770,6 +803,7 @@ export class WorkspaceFlow extends BaseFlow { * Opens `testSuite`'s Tests tab, selects `testSuite`, runs every test in * it, and reads back the resulting pass/fail summary and per-test rows. */ + @step() async runAllTests(testSuite: TestSuite): Promise { const workspace = this.pageManager.workspacePage; await this.openTests(testSuite.collection); @@ -792,6 +826,7 @@ export class WorkspaceFlow extends BaseFlow { * resulting "Invalid Spectral Ruleset" error dialog and returns * `"invalid"` instead, leaving the default ruleset active. */ + @step() async uploadRuleset(filePath: string, expectCode?: string): Promise { const workspace = this.pageManager.workspacePage; const result = await workspace.uploadRuleset(filePath); @@ -859,6 +894,7 @@ export class WorkspaceFlow extends BaseFlow { /** * Finds the tree node matching `item` and returns it as a Project model. */ + @step() async getProject(item: string | { name: string; id?: string }): Promise { const identity = typeof item === 'string' ? { name: item } : item; const node = await this.pageManager.workspacePage.findItemNode(identity); @@ -961,6 +997,7 @@ export class WorkspaceFlow extends BaseFlow { * Deletes the tree node matching `item` with the sidebar's Delete shortcut * and confirms the dialog. Does nothing if no matching node is found. */ + @step() async deleteViaShortcut( item: | Collection @@ -985,6 +1022,7 @@ export class WorkspaceFlow extends BaseFlow { * reads the "Unknown Fields Detected" warning off the post-scan screen * before confirming the clone. */ + @step() async cloneWithScanReport( item: Project, credentialName: string, diff --git a/packages/insomnia-test/misc/fixtures.ts b/packages/insomnia-test/misc/fixtures.ts index 719d1eff9b..e770bd62b1 100644 --- a/packages/insomnia-test/misc/fixtures.ts +++ b/packages/insomnia-test/misc/fixtures.ts @@ -18,7 +18,6 @@ import { trackHiddenWindowCoverage, } from "./coverage"; import { launchInsomniaElectron, SESSION_ID } from "./launch"; -import { closeOpenStepGroup } from "./step-instrumentation"; export * from "./constants"; export { launchInsomniaElectron, SESSION_ID }; @@ -326,7 +325,6 @@ export const test = base.extend({ await pageManager.workspacePage.navigate(); await disableFocusModeDefault(flowManager); await use({ page: window, pageManager, flowManager }); - await closeOpenStepGroup(); // A test that called AppFlow.restart() leaves the relaunched app // behind — the `insomnia`/`window` fixtures only know the original diff --git a/packages/insomnia-test/misc/step-instrumentation.ts b/packages/insomnia-test/misc/step-instrumentation.ts deleted file mode 100644 index 0350b3579c..0000000000 --- a/packages/insomnia-test/misc/step-instrumentation.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { test } from "@playwright/test"; - -const step = test.step.bind(test); - -const EXCLUDED_METHODS = new Set([ - "setContext", - "switchTab", - "fillOneLineEditor", - "readCodeMirror", - "setKeyValuePairs", - "readKeyValuePairs", - "createProject", - "createCollection", - "createDocument", - "createMcpClient", - "createEnvironment", - "rows", - "fillField", - "editCookie", - "caCertificateRow", - "clientCertificateRow", - "fileCard", - "dragRow", - "invitationRow", - "oAuth1FieldEditor", - "oauth2FieldEditor", - "oauth2TokenInput", - "projectTypeTile", - "isToggleOn", - "setToggle", - "tab", - "openAddTabMenu", - "toAiUrlBackendSettings", -]); - -const GET_METHOD_PATTERN = /^get[A-Z]/; - -function humanize(name: string): string { - const words = name - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - return words - .replace(/Graph Ql/g, "GraphQL") - .replace(/Socket Io/g, "Socket.IO") - .replace(/\bMcp\b/g, "MCP") - .replace(/\bGrpc\b/g, "gRPC"); -} - -const BARE_VERB_PATTERN = /^[a-z]+$/; -const CLASS_SUFFIX_PATTERN = /(Flow|Page)$/; - -interface IdentifiedObjectArg { - pos: number; - identifier: string; - typeLabel?: string; -} - -function describeArgs(className: string, key: string, args: unknown[]): string { - let stringArg: string | undefined; - let stringPos = -1; - const objectArgs: IdentifiedObjectArg[] = []; - - args.forEach((arg, pos) => { - if (typeof arg === "string") { - if (stringArg === undefined) { - stringArg = arg; - stringPos = pos; - } - return; - } - if (!arg || typeof arg !== "object" || Array.isArray(arg)) return; - - const candidate = - (arg as { name?: unknown; id?: unknown }).name ?? - (arg as { name?: unknown; id?: unknown }).id; - if (typeof candidate !== "string") return; - - const ctorName = (arg as object).constructor?.name; - objectArgs.push({ - pos, - identifier: candidate, - typeLabel: - ctorName && ctorName !== "Object" ? humanize(ctorName) : undefined, - }); - }); - - const preposition = key.toLowerCase().startsWith("create") ? "in" : "to"; - const last = objectArgs[objectArgs.length - 1]; - - const domain = humanize(className.replace(CLASS_SUFFIX_PATTERN, "")); - let subject = last?.typeLabel ?? ""; - if (subject === domain) subject = ""; - if (!subject && BARE_VERB_PATTERN.test(key) && domain) subject = domain; - - const primitives = [ - stringArg !== undefined ? { value: stringArg, pos: stringPos } : null, - last ? { value: last.identifier, pos: last.pos } : null, - ] - .filter((p): p is { value: string; pos: number } => p !== null) - .sort((a, b) => a.pos - b.pos) - .map((p) => p.value); - let detail = primitives.length ? ` (${primitives.join(", ")})` : ""; - - for (const target of objectArgs.slice(0, -1)) { - detail += ` ${preposition}${target.typeLabel ? ` ${target.typeLabel}` : ""} (${target.identifier})`; - } - - return (subject ? ` ${subject}` : "") + detail; -} - -interface QueueItem { - childTitle: string; - fn: () => unknown; - resolve: (v: unknown) => void; - reject: (e: unknown) => void; -} - -interface PageGroup { - instance: object; - run: (childTitle: string, fn: () => unknown) => Promise; - close: () => Promise; -} - -let isProcessingGroupItem = false; - -function createPageGroup(instance: object, title: string): PageGroup { - const queue: QueueItem[] = []; - let wake: (() => void) | null = null; - let closed = false; - - const finished = step(title, async () => { - for (;;) { - const item = queue.shift(); - if (item) { - isProcessingGroupItem = true; - try { - item.resolve(await step(item.childTitle, item.fn)); - } catch (e) { - item.reject(e); - } finally { - isProcessingGroupItem = false; - } - continue; - } - if (closed) return; - await new Promise((resolve) => (wake = resolve)); - } - }); - - return { - instance, - run(childTitle, fn) { - if (isProcessingGroupItem) return Promise.resolve(step(childTitle, fn)); - return new Promise((resolve, reject) => { - queue.push({ childTitle, fn, resolve, reject }); - wake?.(); - wake = null; - }); - }, - async close() { - closed = true; - wake?.(); - wake = null; - await finished; - }, - }; -} - -let currentPageGroup: PageGroup | null = null; - -async function getPageGroup( - instance: object, - humanClassName: string, -): Promise { - if (currentPageGroup?.instance === instance) return currentPageGroup; - if (isProcessingGroupItem && currentPageGroup) return currentPageGroup; - if (currentPageGroup) { - const previous = currentPageGroup; - currentPageGroup = null; - await previous.close(); - } - const group = createPageGroup(instance, humanClassName); - currentPageGroup = group; - return group; -} - -export async function closeOpenStepGroup(): Promise { - if (!currentPageGroup) return; - const group = currentPageGroup; - currentPageGroup = null; - await group.close(); -} - -export function instrumentWithSteps(instance: T): T { - const className = instance.constructor.name; - const isPageClass = className.endsWith("Page"); - const humanClassName = humanize(className); - const seen = new Set(["constructor"]); - let proto = Object.getPrototypeOf(instance); - - while (proto && proto !== Object.prototype) { - for (const key of Object.getOwnPropertyNames(proto)) { - if (seen.has(key) || EXCLUDED_METHODS.has(key)) continue; - seen.add(key); - if (isPageClass && GET_METHOD_PATTERN.test(key)) continue; - - const descriptor = Object.getOwnPropertyDescriptor(proto, key); - if (!descriptor || typeof descriptor.value !== "function") continue; - - const original = (instance as any)[key] as ( - ...args: unknown[] - ) => unknown; - const childTitle = (args: unknown[]) => - `${humanize(key)}${describeArgs(className, key, args)}`; - - (instance as any)[key] = isPageClass - ? async function (...args: unknown[]) { - const group = await getPageGroup(instance, humanClassName); - return group.run(childTitle(args), () => - original.apply(instance, args), - ); - } - : async function (...args: unknown[]) { - await closeOpenStepGroup(); - try { - return await step(`${humanClassName}: ${childTitle(args)}`, () => - original.apply(instance, args), - ); - } finally { - await closeOpenStepGroup(); - } - }; - } - proto = Object.getPrototypeOf(proto); - } - - return instance; -} diff --git a/packages/insomnia-test/misc/step.ts b/packages/insomnia-test/misc/step.ts new file mode 100644 index 0000000000..100d7b17dc --- /dev/null +++ b/packages/insomnia-test/misc/step.ts @@ -0,0 +1,104 @@ +import { test } from "@playwright/test"; + +function humanize(name: string): string { + const words = name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + return words + .replace(/Graph Ql/g, "GraphQL") + .replace(/Socket Io/g, "Socket.IO") + .replace(/\bMcp\b/g, "MCP") + .replace(/\bGrpc\b/g, "gRPC"); +} + +const BARE_VERB_PATTERN = /^[a-z]+$/; +const CLASS_SUFFIX_PATTERN = /(Flow|Page)$/; + +interface IdentifiedObjectArg { + pos: number; + identifier: string; + typeLabel?: string; +} + +function describeArgs(className: string, key: string, args: unknown[]): string { + let stringArg: string | undefined; + let stringPos = -1; + const objectArgs: IdentifiedObjectArg[] = []; + + args.forEach((arg, pos) => { + if (typeof arg === "string") { + if (stringArg === undefined) { + stringArg = arg; + stringPos = pos; + } + return; + } + if (!arg || typeof arg !== "object" || Array.isArray(arg)) return; + + const candidate = + (arg as { name?: unknown; id?: unknown }).name ?? + (arg as { name?: unknown; id?: unknown }).id; + if (typeof candidate !== "string") return; + + const ctorName = (arg as object).constructor?.name; + objectArgs.push({ + pos, + identifier: candidate, + typeLabel: + ctorName && ctorName !== "Object" ? humanize(ctorName) : undefined, + }); + }); + + const preposition = key.toLowerCase().startsWith("create") ? "in" : "to"; + const last = objectArgs[objectArgs.length - 1]; + + const domain = humanize(className.replace(CLASS_SUFFIX_PATTERN, "")); + let subject = last?.typeLabel ?? ""; + if (subject === domain) subject = ""; + if (!subject && BARE_VERB_PATTERN.test(key) && domain) subject = domain; + + const primitives = [ + stringArg !== undefined ? { value: stringArg, pos: stringPos } : null, + last ? { value: last.identifier, pos: last.pos } : null, + ] + .filter((p): p is { value: string; pos: number } => p !== null) + .sort((a, b) => a.pos - b.pos) + .map((p) => p.value); + let detail = primitives.length ? ` (${primitives.join(", ")})` : ""; + + for (const target of objectArgs.slice(0, -1)) { + detail += ` ${preposition}${target.typeLabel ? ` ${target.typeLabel}` : ""} (${target.identifier})`; + } + + return (subject ? ` ${subject}` : "") + detail; +} + +/** + * Method decorator that reports a Flow method as a `test.step()`. + * + * Without a `title` the step is named after the method and its arguments + * (e.g. `Http Request Flow: Create (Request name)`); pass a `title` to + * override that. + */ +export function step(title?: string) { + return function ( + target: (this: This, ...args: Args) => Promise, + context: ClassMethodDecoratorContext< + This, + (this: This, ...args: Args) => Promise + >, + ) { + const key = String(context.name); + return function (this: This, ...args: Args): Promise { + const className = this.constructor.name; + const stepTitle = + title ?? `${humanize(key)}${describeArgs(className, key, args)}`; + return test.step(`${humanize(className)}: ${stepTitle}`, () => + target.call(this, ...args), + ); + }; + }; +} diff --git a/packages/insomnia-test/pages/page-manager.ts b/packages/insomnia-test/pages/page-manager.ts index 12be732249..f5d9df8a37 100644 --- a/packages/insomnia-test/pages/page-manager.ts +++ b/packages/insomnia-test/pages/page-manager.ts @@ -1,7 +1,6 @@ import type { ElectronApplication } from "@playwright/test"; import type { Page } from "playwright-core"; -import { instrumentWithSteps } from "../misc/step-instrumentation"; import type { BasePage } from "./base.page"; import { CertificatesPage } from "./certificates.page"; import { CloudSyncPage } from "./cloud-sync.page"; @@ -282,8 +281,7 @@ export class PageManager { } private getOrCreate(key: string, factory: () => T): T { - if (!this.instances.has(key)) - this.instances.set(key, instrumentWithSteps(factory())); + if (!this.instances.has(key)) this.instances.set(key, factory()); return this.instances.get(key) as T; } } diff --git a/packages/insomnia-test/playwright.config.ts b/packages/insomnia-test/playwright.config.ts index e9578a2e63..3125009985 100644 --- a/packages/insomnia-test/playwright.config.ts +++ b/packages/insomnia-test/playwright.config.ts @@ -29,7 +29,7 @@ const INSOMNIA_SRC_PACKAGE = path.resolve(import.meta.dirname, "..", "insomnia") ensureCerts(); // The suite is serial by design: misc/git-fixtures.ts (`currentGitRepo`), -// misc/step-instrumentation.ts (`currentPageGroup`) and the mock servers' +// module-level state and the mock servers' // shared state all assume one test at a time. Fail fast instead of letting // `--workers=N` produce cross-test interference. const workersIdx = process.argv.findIndex((arg) => arg === "--workers" || arg.startsWith("--workers=") || arg === "-j");