From 01915d784e784d9e216d0c2cd8cad4ca70deac08 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 09:28:14 +1200 Subject: [PATCH 1/9] Add OC-110 runtime authoring UX --- examples/demo-api/GraphQL/graphql-health.yml | 2 +- examples/demo-api/Runtime/folder.yml | 2 +- .../demo-api/Runtime/intentional-failure.yml | 2 +- examples/demo-api/Runtime/lifecycle-token.yml | 6 +- examples/demo-api/Runtime/scripted-echo.yml | 2 +- .../demo-api/WebSocket/runtime-lifecycle.yml | 2 +- .../demo-api/gRPC/runtime-unary-lifecycle.yml | 2 +- schema/missio-extensions.json | 14 + schema/opencollectionschema.json | 4 + src/models/schemaRoundTrip.ts | 315 ++++++++++++++++-- src/models/types.ts | 1 + src/panels/requestPanel.ts | 30 ++ src/services/runtimeExecutionService.ts | 2 +- src/webview/requestPanel.css | 142 ++++++++ src/webview/requestPanel.ts | 228 ++++++++++++- test/runtimeAuthoringUx.test.ts | 257 ++++++++++++++ test/runtimeExecutionService.test.ts | 34 ++ test/validationService.test.ts | 4 + 18 files changed, 1015 insertions(+), 34 deletions(-) create mode 100644 test/runtimeAuthoringUx.test.ts diff --git a/examples/demo-api/GraphQL/graphql-health.yml b/examples/demo-api/GraphQL/graphql-health.yml index 14887e3..8762ca1 100644 --- a/examples/demo-api/GraphQL/graphql-health.yml +++ b/examples/demo-api/GraphQL/graphql-health.yml @@ -2,7 +2,7 @@ info: name: GraphQL Health type: graphql seq: 1 - description: Run node examples/demo-api/server.js first, then send this health query to the local GraphQL fixture. + description: Run node examples/demo-api/server.js first, optionally edit assertions in the request Runtime tab, then send this health query. graphql: method: POST url: "{{baseUrl}}/graphql" diff --git a/examples/demo-api/Runtime/folder.yml b/examples/demo-api/Runtime/folder.yml index 95397ec..efcf985 100644 --- a/examples/demo-api/Runtime/folder.yml +++ b/examples/demo-api/Runtime/folder.yml @@ -1,7 +1,7 @@ info: name: Runtime type: folder - description: Run `node examples/demo-api/server.js` before sending these runtime demo requests. + description: Run `node examples/demo-api/server.js`, then open a request's Runtime tab to edit scripts, tests, assertions, and set-variable actions before sending. request: headers: - name: X-Demo-Folder diff --git a/examples/demo-api/Runtime/intentional-failure.yml b/examples/demo-api/Runtime/intentional-failure.yml index 020f013..0a8ff70 100644 --- a/examples/demo-api/Runtime/intentional-failure.yml +++ b/examples/demo-api/Runtime/intentional-failure.yml @@ -2,7 +2,7 @@ info: name: Runtime Intentional Failure type: http seq: 3 - description: Run `node examples/demo-api/server.js`, then send this request to see failing assertions and tests surfaced in the Runtime tab and tool output. + description: Run `node examples/demo-api/server.js`, adjust the request Runtime tab, then send to see failing assertions and tests in the response Runtime tab and tool output. http: method: GET url: "{{baseUrl}}/runtime/assert-fail" diff --git a/examples/demo-api/Runtime/lifecycle-token.yml b/examples/demo-api/Runtime/lifecycle-token.yml index 8a8a60f..5168a17 100644 --- a/examples/demo-api/Runtime/lifecycle-token.yml +++ b/examples/demo-api/Runtime/lifecycle-token.yml @@ -2,7 +2,7 @@ info: name: Runtime Lifecycle Token type: http seq: 1 - description: Run `node examples/demo-api/server.js`, then send this request to see passing scripts, tests, assertions, logs, and a set-variable action. + description: Run `node examples/demo-api/server.js`, edit scripts/assertions/actions in the request Runtime tab if desired, then send to see passing runtime output. http: method: POST url: "{{baseUrl}}/runtime/token" @@ -34,6 +34,10 @@ runtime: code: |- pm.test("status is 200", () => pm.expect(pm.response.code).to.eql(200)); test("token action source exists", () => assert(response.json().nested.owner === "script-owner")); + - type: tests + disabled: true + code: |- + test("disabled test is preserved", () => assert(false)); assertions: - expression: res.status operator: equals diff --git a/examples/demo-api/Runtime/scripted-echo.yml b/examples/demo-api/Runtime/scripted-echo.yml index eaaa8d2..7e16e7c 100644 --- a/examples/demo-api/Runtime/scripted-echo.yml +++ b/examples/demo-api/Runtime/scripted-echo.yml @@ -2,7 +2,7 @@ info: name: Runtime Scripted Echo type: http seq: 2 - description: Run `node examples/demo-api/server.js`, then send this request to verify before-request scripts can set headers and runtime variables before HTTP execution. + description: Run `node examples/demo-api/server.js`, edit the before-request script from the request Runtime tab, then send to verify script-authored headers and variables. http: method: POST url: "{{baseUrl}}/runtime/echo" diff --git a/examples/demo-api/WebSocket/runtime-lifecycle.yml b/examples/demo-api/WebSocket/runtime-lifecycle.yml index 949dc2f..bbb2a13 100644 --- a/examples/demo-api/WebSocket/runtime-lifecycle.yml +++ b/examples/demo-api/WebSocket/runtime-lifecycle.yml @@ -48,4 +48,4 @@ runtime: variable: scope: runtime name: runtimeSocketMessages -docs: Run `node examples/demo-api/server.js`, select the LOCAL environment, then send this request to see WebSocket before/after scripts, assertions, tests, and set-variable actions. +docs: Run `node examples/demo-api/server.js`, select the LOCAL environment, optionally edit the request Runtime tab, then send to see WebSocket scripts, assertions, tests, and set-variable actions. diff --git a/examples/demo-api/gRPC/runtime-unary-lifecycle.yml b/examples/demo-api/gRPC/runtime-unary-lifecycle.yml index 5b22959..4bd2950 100644 --- a/examples/demo-api/gRPC/runtime-unary-lifecycle.yml +++ b/examples/demo-api/gRPC/runtime-unary-lifecycle.yml @@ -59,4 +59,4 @@ runtime: variable: scope: runtime name: grpcRuntimeRequestId -docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, then send this request to see unary gRPC metadata/message mutation plus runtime tests, assertions, logs, and set-variable actions. +docs: Run `node examples/demo-api/grpc-server.js`, select the LOCAL environment, optionally edit the request Runtime tab, then send to see unary gRPC metadata/message mutation plus runtime tests, assertions, logs, and set-variable actions. diff --git a/schema/missio-extensions.json b/schema/missio-extensions.json index ca28e8a..5098076 100644 --- a/schema/missio-extensions.json +++ b/schema/missio-extensions.json @@ -24,6 +24,10 @@ { "extension": "non-HTTP runtime actions and WebSocket assertions", "reason": "Missio runs the same scripts, assertions, tests, and set-variable actions across supported protocol executors. The upstream schema omits some runtime lifecycle fields from WebSocket and gRPC runtime objects, so Missio extends them for protocol parity." + }, + { + "extension": "disabled runtime scripts", + "reason": "Missio's visual request editor can toggle runtime scripts without deleting them. The disabled flag keeps authored scripts present in YAML while letting the runtime skip them." } ], "definitions": { @@ -190,6 +194,16 @@ } } }, + { + "description": "Allow runtime scripts to be disabled without deleting them", + "target": "$defs/Script/properties", + "properties": { + "disabled": { + "type": "boolean", + "description": "Whether the runtime script is disabled" + } + } + }, { "description": "Allow runtime actions on gRPC requests", "target": "$defs/GrpcRequestRuntime/properties", diff --git a/schema/opencollectionschema.json b/schema/opencollectionschema.json index 6dc71ea..e748742 100644 --- a/schema/opencollectionschema.json +++ b/schema/opencollectionschema.json @@ -2508,6 +2508,10 @@ "code": { "type": "string", "description": "The script code" + }, + "disabled": { + "type": "boolean", + "description": "Whether the runtime script is disabled" } }, "required": [ diff --git a/src/models/schemaRoundTrip.ts b/src/models/schemaRoundTrip.ts index 02d3cf4..d88722a 100644 --- a/src/models/schemaRoundTrip.ts +++ b/src/models/schemaRoundTrip.ts @@ -20,6 +20,40 @@ export interface FormFieldEditorRow extends KeyValueEditorRow { partType?: string; } +export interface RuntimeScriptEditorRow { + type: string; + code: string; + disabled?: boolean; + originalIndex?: number; +} + +export interface RuntimeAssertionEditorRow { + expression: string; + operator: string; + value?: string; + disabled?: boolean; + description?: string; + originalIndex?: number; +} + +export interface RuntimeActionEditorRow { + type: string; + phase?: string; + selectorMethod?: string; + selectorExpression?: string; + variableScope?: string; + variableName?: string; + disabled?: boolean; + description?: string; + originalIndex?: number; +} + +export interface RuntimeEditorModel { + scripts?: RuntimeScriptEditorRow[]; + assertions?: RuntimeAssertionEditorRow[]; + actions?: RuntimeActionEditorRow[]; +} + export type RequestEditorBodyModel = | { kind: 'none'; bodyVariantIndex?: number } | { kind: 'raw'; rawType: string; data: string; bodyVariantIndex?: number } @@ -35,6 +69,7 @@ export interface RequestEditorModel { headers?: KeyValueEditorRow[]; body?: RequestEditorBodyModel; auth?: unknown; + runtime?: RuntimeEditorModel; settings?: Record; } @@ -154,7 +189,7 @@ export function isWebSocketVisualEditableRequest(data: unknown): boolean { export function isVisualEditableRequest(data: unknown): boolean { const protocol = detectRequestProtocol(data); - return (protocol === undefined || protocol === 'http' || protocol === 'graphql' || protocol === 'websocket') && !isScriptDocument(data); + return (protocol === undefined || protocol === 'http' || protocol === 'graphql' || protocol === 'websocket' || protocol === 'grpc') && !isScriptDocument(data); } function withOptionalDisabled>( @@ -182,6 +217,11 @@ function getByOriginalIndex(items: T[] | undefined, row: { originalIndex?: nu return items[index]; } +function getByExplicitOriginalIndex(items: T[] | undefined, row: { originalIndex?: number }): T | undefined { + if (!items || row.originalIndex === undefined) return undefined; + return items[row.originalIndex]; +} + function mergeHeaders(previous: unknown[] | undefined, rows: KeyValueEditorRow[] | undefined): unknown[] | undefined { if (!rows) return undefined; if (rows.length === 0) return Array.isArray(previous) ? [] : undefined; @@ -259,6 +299,17 @@ function selectedWebSocketMessage(message: unknown): { message: unknown; index?: }; } +function selectedGrpcMessage(message: unknown): { message: unknown; index?: number } { + if (!Array.isArray(message)) return { message }; + const selectedIndex = message.findIndex(variant => isObject(variant) && variant.selected === true); + const index = selectedIndex >= 0 ? selectedIndex : 0; + const variant = message[index]; + return { + message: isObject(variant) && typeof variant.message === 'string' ? variant.message : undefined, + index, + }; +} + function selectedFileVariant(body: unknown): { variant: unknown; index?: number } { if (!isObject(body) || !Array.isArray(body.data)) return { variant: undefined }; const selectedIndex = body.data.findIndex(variant => isObject(variant) && variant.selected === true); @@ -283,6 +334,70 @@ function formRowsFromBody(body: Record): FormFieldEditorRow[] { }); } +function descriptionToText(description: unknown): string | undefined { + if (typeof description === 'string') return description; + if (isObject(description) && typeof description.content === 'string') return description.content; + return undefined; +} + +function mergeDescription(output: Record, prior: unknown, description: string | undefined): void { + const priorDescription = isObject(prior) ? prior.description : undefined; + if (description === undefined) return; + if (description) { + output.description = description === descriptionToText(priorDescription) ? cloneJson(priorDescription) : description; + } else if (hasOwn(prior, 'description')) { + delete output.description; + } +} + +function createRuntimeEditorModel(runtime: unknown): RuntimeEditorModel { + const source = isObject(runtime) ? runtime : {}; + return { + scripts: Array.isArray(source.scripts) + ? source.scripts.map((script, index) => { + const row = isObject(script) ? script : {}; + return { + type: typeof row.type === 'string' ? row.type : 'before-request', + code: typeof row.code === 'string' ? row.code : '', + disabled: row.disabled === true, + originalIndex: index, + }; + }) + : [], + assertions: Array.isArray(source.assertions) + ? source.assertions.map((assertion, index) => { + const row = isObject(assertion) ? assertion : {}; + return { + expression: typeof row.expression === 'string' ? row.expression : '', + operator: typeof row.operator === 'string' ? row.operator : 'equals', + value: typeof row.value === 'string' ? row.value : row.value === undefined ? undefined : displayValue(row.value), + disabled: row.disabled === true, + description: descriptionToText(row.description), + originalIndex: index, + }; + }) + : [], + actions: Array.isArray(source.actions) + ? source.actions.map((action, index) => { + const row = isObject(action) ? action : {}; + const selector = isObject(row.selector) ? row.selector : {}; + const variable = isObject(row.variable) ? row.variable : {}; + return { + type: typeof row.type === 'string' ? row.type : 'set-variable', + phase: typeof row.phase === 'string' ? row.phase : 'after-response', + selectorMethod: typeof selector.method === 'string' ? selector.method : 'jsonq', + selectorExpression: typeof selector.expression === 'string' ? selector.expression : '', + variableScope: typeof variable.scope === 'string' ? variable.scope : 'runtime', + variableName: typeof variable.name === 'string' ? variable.name : '', + disabled: row.disabled === true, + description: descriptionToText(row.description), + originalIndex: index, + }; + }) + : [], + }; +} + export function createRequestEditorModelFromRequest(request: unknown): RequestEditorModel { const protocol = detectRequestProtocol(request) ?? 'http'; const source = isObject(request) ? request : {}; @@ -327,6 +442,7 @@ export function createRequestEditorModelFromRequest(request: unknown): RequestEd bodyVariantIndex, }, auth: runtime.auth, + runtime: createRuntimeEditorModel(runtime), settings: isObject(source.settings) ? cloneJson(source.settings) : {}, }; } @@ -363,6 +479,34 @@ export function createRequestEditorModelFromRequest(request: unknown): RequestEd } : { kind: 'none', bodyVariantIndex: messageVariantIndex }, auth: runtime.auth, + runtime: createRuntimeEditorModel(runtime), + }; + } + + if (protocol === 'grpc') { + const grpc = isObject(source.grpc) ? source.grpc : {}; + const runtime = isObject(source.runtime) ? source.runtime : {}; + const { message, index: messageIndex } = selectedGrpcMessage(grpc.message); + + return { + protocol, + url: typeof grpc.url === 'string' ? grpc.url : '', + headers: Array.isArray(grpc.metadata) + ? grpc.metadata.map((metadata, index) => { + const row = isObject(metadata) ? metadata : {}; + return { + name: typeof row.name === 'string' ? row.name : '', + value: typeof row.value === 'string' ? row.value : '', + disabled: row.disabled === true, + originalIndex: index, + }; + }) + : [], + body: typeof message === 'string' + ? { kind: 'raw', rawType: 'json', data: message, bodyVariantIndex: messageIndex } + : { kind: 'none', bodyVariantIndex: messageIndex }, + auth: runtime.auth, + runtime: createRuntimeEditorModel(runtime), }; } @@ -432,6 +576,7 @@ export function createRequestEditorModelFromRequest(request: unknown): RequestEd : [], body: bodyModel, auth: runtime.auth, + runtime: createRuntimeEditorModel(runtime), settings: isObject(source.settings) ? cloneJson(source.settings) : {}, }; } @@ -597,6 +742,114 @@ function buildWebSocketMessageFromEditor(originalMessage: unknown, model: Reques return variants; } +function buildGrpcMessageFromEditor(originalMessage: unknown, model: RequestEditorBodyModel): unknown | undefined { + if (model.kind === 'none') return undefined; + if (model.kind !== 'raw') return selectedGrpcMessage(originalMessage).message; + + if (!Array.isArray(originalMessage)) return model.data; + + const messages = cloneJson(originalMessage); + const index = model.bodyVariantIndex ?? messages.findIndex(variant => isObject(variant) && variant.selected === true); + const resolvedIndex = index >= 0 ? index : 0; + const previousEntry = isObject(messages[resolvedIndex]) ? messages[resolvedIndex] as Record : {}; + messages[resolvedIndex] = { + ...previousEntry, + message: model.data, + }; + return messages; +} + +function mergeRuntimeScripts(previous: unknown[] | undefined, rows: RuntimeScriptEditorRow[] | undefined): unknown[] | undefined { + if (!rows) return undefined; + if (rows.length === 0) return Array.isArray(previous) ? [] : undefined; + return rows + .filter(row => row.type && row.code !== undefined) + .map((row) => { + const prior = getByExplicitOriginalIndex(previous, row); + const output: Record = isObject(prior) ? cloneJson(prior) : {}; + output.type = row.type; + output.code = row.code; + return withOptionalDisabled(output, prior, row.disabled); + }); +} + +function mergeRuntimeAssertions(previous: unknown[] | undefined, rows: RuntimeAssertionEditorRow[] | undefined): unknown[] | undefined { + if (!rows) return undefined; + if (rows.length === 0) return Array.isArray(previous) ? [] : undefined; + return rows + .filter(row => row.expression && row.operator) + .map((row) => { + const prior = getByExplicitOriginalIndex(previous, row); + const output: Record = isObject(prior) ? cloneJson(prior) : {}; + output.expression = row.expression; + output.operator = row.operator; + if (row.value || hasOwn(prior, 'value')) output.value = row.value ?? ''; + else delete output.value; + mergeDescription(output, prior, row.description); + return withOptionalDisabled(output, prior, row.disabled); + }); +} + +function mergeRuntimeActions(previous: unknown[] | undefined, rows: RuntimeActionEditorRow[] | undefined): unknown[] | undefined { + if (!rows) return undefined; + if (rows.length === 0) return Array.isArray(previous) ? [] : undefined; + return rows + .filter(row => row.type === 'set-variable' && row.selectorExpression && row.variableName) + .map((row) => { + const prior = getByExplicitOriginalIndex(previous, row); + const output: Record = isObject(prior) ? cloneJson(prior) : {}; + output.type = 'set-variable'; + if (row.phase || hasOwn(prior, 'phase')) output.phase = row.phase || 'after-response'; + else delete output.phase; + output.selector = { + ...(isObject(output.selector) ? output.selector : {}), + method: row.selectorMethod || 'jsonq', + expression: row.selectorExpression, + }; + output.variable = { + ...(isObject(output.variable) ? output.variable : {}), + scope: row.variableScope || 'runtime', + name: row.variableName, + }; + mergeDescription(output, prior, row.description); + return withOptionalDisabled(output, prior, row.disabled); + }); +} + +function mergeRuntime( + previous: unknown, + auth: unknown, + authProvided: boolean, + runtimeModel: RuntimeEditorModel | undefined, +): Record | undefined { + const runtime: Record = isObject(previous) ? cloneJson(previous) : {}; + + if (authProvided) { + if (auth !== undefined) runtime.auth = cloneJson(auth); + else delete runtime.auth; + } + + if (runtimeModel) { + if (hasOwn(runtimeModel, 'scripts')) { + const scripts = mergeRuntimeScripts(Array.isArray(runtime.scripts) ? runtime.scripts : undefined, runtimeModel.scripts); + if (scripts) runtime.scripts = scripts; + else delete runtime.scripts; + } + if (hasOwn(runtimeModel, 'assertions')) { + const assertions = mergeRuntimeAssertions(Array.isArray(runtime.assertions) ? runtime.assertions : undefined, runtimeModel.assertions); + if (assertions) runtime.assertions = assertions; + else delete runtime.assertions; + } + if (hasOwn(runtimeModel, 'actions')) { + const actions = mergeRuntimeActions(Array.isArray(runtime.actions) ? runtime.actions : undefined, runtimeModel.actions); + if (actions) runtime.actions = actions; + else delete runtime.actions; + } + } + + return isEmptyObject(runtime) ? undefined : runtime; +} + function mergeSettings(previous: unknown, settings: Record | undefined): Record | undefined { if (!settings) return isObject(previous) ? cloneJson(previous) : undefined; const output: Record = isObject(previous) ? cloneJson(previous) : {}; @@ -614,7 +867,7 @@ function mergeSettings(previous: unknown, settings: Record | un } export function applyRequestEditorModel(original: unknown, model: RequestEditorModel): unknown { - if (!isVisualEditableRequest(original) || (model.protocol && model.protocol !== 'http' && model.protocol !== 'graphql' && model.protocol !== 'websocket')) { + if (!isVisualEditableRequest(original) || (model.protocol && model.protocol !== 'http' && model.protocol !== 'graphql' && model.protocol !== 'websocket' && model.protocol !== 'grpc')) { return cloneJson(original); } @@ -648,13 +901,9 @@ export function applyRequestEditorModel(original: unknown, model: RequestEditorM else graphql.body = body; } - if (model.auth !== undefined || isObject(request.runtime)) { - const runtime: Record = isObject(request.runtime) ? cloneJson(request.runtime) : {}; - if (model.auth !== undefined) runtime.auth = cloneJson(model.auth); - else delete runtime.auth; - if (isEmptyObject(runtime)) delete request.runtime; - else request.runtime = runtime; - } + const runtime = mergeRuntime(request.runtime, model.auth, hasOwn(model, 'auth'), model.runtime); + if (runtime) request.runtime = runtime; + else delete request.runtime; const settings = mergeSettings(request.settings, model.settings); if (settings) request.settings = settings; @@ -685,14 +934,40 @@ export function applyRequestEditorModel(original: unknown, model: RequestEditorM else websocket.message = message; } - if (model.auth !== undefined || isObject(request.runtime)) { - const runtime: Record = isObject(request.runtime) ? cloneJson(request.runtime) : {}; - if (model.auth !== undefined) runtime.auth = cloneJson(model.auth); - else delete runtime.auth; - if (isEmptyObject(runtime)) delete request.runtime; - else request.runtime = runtime; + const runtime = mergeRuntime(request.runtime, model.auth, hasOwn(model, 'auth'), model.runtime); + if (runtime) request.runtime = runtime; + else delete request.runtime; + + return request; + } + + if (targetProtocol === 'grpc') { + const source = isObject(original) ? original : {}; + const request: Record = cloneJson(source); + if (!isObject(request.info)) request.info = { type: 'grpc' }; + else (request.info as Record).type = 'grpc'; + if (!isObject(request.grpc)) request.grpc = {}; + delete request.http; + delete request.graphql; + delete request.websocket; + + const grpc = request.grpc as Record; + if (model.url !== undefined) grpc.url = model.url; + if (model.headers !== undefined) { + const merged = mergeMetadata(Array.isArray(grpc.metadata) ? grpc.metadata : undefined, model.headers); + if (merged) grpc.metadata = merged; + else delete grpc.metadata; + } + if (model.body !== undefined) { + const message = buildGrpcMessageFromEditor(grpc.message, model.body); + if (message === undefined) delete grpc.message; + else grpc.message = message; } + const runtime = mergeRuntime(request.runtime, model.auth, hasOwn(model, 'auth'), model.runtime); + if (runtime) request.runtime = runtime; + else delete request.runtime; + return request; } @@ -720,13 +995,9 @@ export function applyRequestEditorModel(original: unknown, model: RequestEditorM else http.body = body; } - if (model.auth !== undefined || isObject(request.runtime)) { - const runtime: Record = isObject(request.runtime) ? cloneJson(request.runtime) : {}; - if (model.auth !== undefined) runtime.auth = cloneJson(model.auth); - else delete runtime.auth; - if (isEmptyObject(runtime)) delete request.runtime; - else request.runtime = runtime; - } + const runtime = mergeRuntime(request.runtime, model.auth, hasOwn(model, 'auth'), model.runtime); + if (runtime) request.runtime = runtime; + else delete request.runtime; const settings = mergeSettings(request.settings, model.settings); if (settings) request.settings = settings; diff --git a/src/models/types.ts b/src/models/types.ts index fc8856a..0fa4400 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -69,6 +69,7 @@ export type ScriptType = 'before-request' | 'after-response' | 'tests' | 'hooks' export interface Script { type: ScriptType; code: string; + disabled?: boolean; } export type Scripts = Script[]; diff --git a/src/panels/requestPanel.ts b/src/panels/requestPanel.ts index 99fabd8..74f4eb5 100644 --- a/src/panels/requestPanel.ts +++ b/src/panels/requestPanel.ts @@ -839,6 +839,7 @@ export class RequestEditorProvider extends BaseEditorProvider {
Auth
Headers 0
Params 0
+
Runtime 0
Settings
Export
@@ -947,6 +948,35 @@ export class RequestEditorProvider extends BaseEditorProvider { + +
+
+
+
+

Scripts

+
+ + +
+
+
+
+
+
+

Assertions

+ +
+
+
+
+
+

Actions

+ +
+
+
+
+
diff --git a/src/services/runtimeExecutionService.ts b/src/services/runtimeExecutionService.ts index c7336b7..8602876 100644 --- a/src/services/runtimeExecutionService.ts +++ b/src/services/runtimeExecutionService.ts @@ -444,7 +444,7 @@ function lifecycleScripts( ...(collectionScripts ?? []), ...(folderScripts ?? []), ...(requestScripts ?? []), - ].filter(script => script.type === type); + ].filter(script => !script.disabled && script.type === type); } function cloneJson(value: T): T { diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index 9b0f815..4eaea52 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -464,6 +464,148 @@ body { .tab-panel { display: none; height: 100%; } .tab-panel.active { display: flex; flex-direction: column; } +/* Runtime Authoring */ +.runtime-authoring { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; + padding-bottom: 8px; +} +.runtime-authoring-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.runtime-authoring-header { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} +.runtime-authoring-header h3 { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--m-fg-muted); + flex: 1; +} +.runtime-authoring-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.runtime-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.runtime-list:empty::before { + content: 'No entries'; + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--m-fg-dim); + font-size: 12px; + padding: 10px 12px; +} +.runtime-editor-row { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px; + background: var(--vscode-editor-background, var(--bg)); +} +.runtime-row-toolbar { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} +.runtime-enabled { + margin-right: 2px; +} +.runtime-select, +.runtime-input, +.runtime-code { + background: var(--input-bg); + color: var(--input-fg); + border: 1px solid var(--input-border); + border-radius: 4px; + font-size: 12px; + font-family: var(--vscode-editor-font-family, monospace); + outline: none; +} +.runtime-select { + min-width: 132px; + padding: 4px 8px; +} +.runtime-input { + min-width: 120px; + flex: 1 1 160px; + padding: 5px 8px; +} +.runtime-description { + flex-basis: 100%; +} +.rt-assertion-expression, +.rt-action-selector { + flex-basis: 220px; +} +.rt-action-name { + flex-basis: 140px; +} +.runtime-code { + width: 100%; + min-height: 86px; + resize: vertical; + padding: 8px 10px; + line-height: 1.45; +} +.runtime-select:focus, +.runtime-input:focus, +.runtime-code:focus { + border-color: var(--btn-bg); + outline: 1px solid var(--btn-bg); +} +.runtime-action-type { + color: var(--m-fg-muted); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + white-space: nowrap; +} +.runtime-row-actions { + display: flex; + gap: 4px; + margin-left: auto; +} +.runtime-icon-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--input-border); + border-radius: 4px; + background: transparent; + color: var(--fg); + cursor: pointer; + font-family: inherit; + font-size: 14px; + line-height: 1; +} +.runtime-icon-btn:hover { + border-color: var(--btn-bg); + background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); +} + /* ── Resizable Divider ───────────────────── */ .divider { height: 4px; diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index 902bfb0..152c16c 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -84,6 +84,7 @@ import { type KeyValueEditorRow, type RequestEditorBodyModel, type RequestEditorModel, + type RuntimeEditorModel, } from '../models/schemaRoundTrip'; // ── Document update scheduling ─────────────────── @@ -102,7 +103,7 @@ function scheduleDocumentUpdate(): void { } // ── Tab switching ────────────────────────────── -const reqPanelIds = ['body', 'auth', 'headers', 'params', 'settings', 'export']; +const reqPanelIds = ['body', 'auth', 'headers', 'params', 'runtime', 'settings', 'export']; const respPanelIds = ['resp-body', 'resp-headers', 'resp-runtime', 'resp-preview']; function updateBodyFormatterState(): void { @@ -568,11 +569,182 @@ function addFormField(name = '', value: unknown = '', disabled = false, partType tbody.appendChild(tr); } +const runtimeScriptTypes = ['before-request', 'after-response', 'tests', 'hooks']; +const runtimeAssertionOperators = [ + 'equals', + 'not-equals', + 'contains', + 'exists', + 'not-exists', + 'greater-than', + 'greater-than-or-equal', + 'less-than', + 'less-than-or-equal', + 'matches', +]; +const runtimeActionPhases = ['before-request', 'after-response']; +const runtimeVariableScopes = ['runtime', 'request', 'folder', 'collection', 'environment']; + +function optionsHtml(values: string[], selected: string | undefined): string { + return values.map(value => '').join(''); +} + +function moveRuntimeRow(row: HTMLElement, direction: -1 | 1): void { + const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling; + if (!sibling || !row.parentElement) return; + if (direction < 0) row.parentElement.insertBefore(row, sibling); + else row.parentElement.insertBefore(sibling, row); + scheduleDocumentUpdate(); +} + +function wireRuntimeRow(row: HTMLElement): void { + row.addEventListener('input', scheduleDocumentUpdate); + row.addEventListener('change', scheduleDocumentUpdate); + row.querySelector('.runtime-move-up')?.addEventListener('click', () => moveRuntimeRow(row, -1)); + row.querySelector('.runtime-move-down')?.addEventListener('click', () => moveRuntimeRow(row, 1)); + row.querySelector('.runtime-delete')?.addEventListener('click', () => { + row.remove(); + updateRuntimeBadge(); + scheduleDocumentUpdate(); + }); +} + +function addRuntimeScript(type = 'before-request', code = '', disabled = false, originalIndex?: number): void { + const list = $('runtimeScriptsList'); + const row = document.createElement('div'); + row.className = 'runtime-editor-row runtime-script-row'; + if (originalIndex !== undefined) row.dataset.originalIndex = String(originalIndex); + row.innerHTML = + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + ''; + wireRuntimeRow(row); + list.appendChild(row); + updateRuntimeBadge(); +} + +function addRuntimeAssertion( + expression = 'res.status', + operator = 'equals', + value = '200', + disabled = false, + description = '', + originalIndex?: number, +): void { + const list = $('runtimeAssertionsList'); + const row = document.createElement('div'); + row.className = 'runtime-editor-row runtime-assertion-row'; + if (originalIndex !== undefined) row.dataset.originalIndex = String(originalIndex); + row.innerHTML = + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + ''; + wireRuntimeRow(row); + list.appendChild(row); + updateRuntimeBadge(); +} + +function addRuntimeAction( + phase = 'after-response', + selectorExpression = '$.token', + variableScope = 'runtime', + variableName = 'token', + disabled = false, + description = '', + originalIndex?: number, +): void { + const list = $('runtimeActionsList'); + const row = document.createElement('div'); + row.className = 'runtime-editor-row runtime-action-row'; + if (originalIndex !== undefined) row.dataset.originalIndex = String(originalIndex); + row.innerHTML = + '
' + + '' + + '' + + 'set-variable' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + ''; + wireRuntimeRow(row); + list.appendChild(row); + updateRuntimeBadge(); +} + +function loadRuntimeEditor(runtime: any): void { + $('runtimeScriptsList').innerHTML = ''; + $('runtimeAssertionsList').innerHTML = ''; + $('runtimeActionsList').innerHTML = ''; + (runtime?.scripts || []).forEach((script: any, index: number) => { + addRuntimeScript(script.type || 'before-request', script.code || '', script.disabled, index); + }); + (runtime?.assertions || []).forEach((assertion: any, index: number) => { + const description = typeof assertion.description === 'string' + ? assertion.description + : assertion.description?.content || ''; + addRuntimeAssertion( + assertion.expression || '', + assertion.operator || 'equals', + assertion.value ?? '', + assertion.disabled, + description, + index, + ); + }); + (runtime?.actions || []).forEach((action: any, index: number) => { + const description = typeof action.description === 'string' + ? action.description + : action.description?.content || ''; + addRuntimeAction( + action.phase || 'after-response', + action.selector?.expression || '', + action.variable?.scope || 'runtime', + action.variable?.name || '', + action.disabled, + description, + index, + ); + }); + updateRuntimeBadge(); +} + +function updateRuntimeBadge(): void { + const count = + document.querySelectorAll('#runtimeScriptsList .runtime-editor-row').length + + document.querySelectorAll('#runtimeAssertionsList .runtime-editor-row').length + + document.querySelectorAll('#runtimeActionsList .runtime-editor-row').length; + const badge = document.getElementById('runtimeBadge'); + if (badge) badge.textContent = String(count); +} + function updateBadges(): void { const params = document.querySelectorAll('#paramsBody tr'); const headers = document.querySelectorAll('#headersBody tr:not(.auto-header)'); $('paramsBadge').textContent = String(params.length); $('headersBadge').textContent = String(headers.length); + updateRuntimeBadge(); } // ── Body Type (pills) ─────────────────────────── @@ -1128,6 +1300,47 @@ function collectBodyModel(): RequestEditorBodyModel { }; } +function collectRuntimeModel(): RuntimeEditorModel { + const scripts: RuntimeEditorModel['scripts'] = []; + document.querySelectorAll('#runtimeScriptsList .runtime-script-row').forEach((row) => { + scripts.push({ + type: (row.querySelector('.rt-script-type') as HTMLSelectElement).value, + code: (row.querySelector('.rt-script-code') as HTMLTextAreaElement).value, + disabled: !(row.querySelector('.rt-script-enabled') as HTMLInputElement).checked, + originalIndex: originalIndexFrom(row), + }); + }); + + const assertions: RuntimeEditorModel['assertions'] = []; + document.querySelectorAll('#runtimeAssertionsList .runtime-assertion-row').forEach((row) => { + assertions.push({ + expression: (row.querySelector('.rt-assertion-expression') as HTMLInputElement).value.trim(), + operator: (row.querySelector('.rt-assertion-operator') as HTMLSelectElement).value, + value: (row.querySelector('.rt-assertion-value') as HTMLInputElement).value, + disabled: !(row.querySelector('.rt-assertion-enabled') as HTMLInputElement).checked, + description: (row.querySelector('.rt-assertion-description') as HTMLInputElement).value, + originalIndex: originalIndexFrom(row), + }); + }); + + const actions: RuntimeEditorModel['actions'] = []; + document.querySelectorAll('#runtimeActionsList .runtime-action-row').forEach((row) => { + actions.push({ + type: 'set-variable', + phase: (row.querySelector('.rt-action-phase') as HTMLSelectElement).value, + selectorMethod: 'jsonq', + selectorExpression: (row.querySelector('.rt-action-selector') as HTMLInputElement).value.trim(), + variableScope: (row.querySelector('.rt-action-scope') as HTMLSelectElement).value, + variableName: (row.querySelector('.rt-action-name') as HTMLInputElement).value.trim(), + disabled: !(row.querySelector('.rt-action-enabled') as HTMLInputElement).checked, + description: (row.querySelector('.rt-action-description') as HTMLInputElement).value, + originalIndex: originalIndexFrom(row), + }); + }); + + return { scripts, assertions, actions }; +} + function buildRequestWithSchemaMerge(): any { if (!isVisualEditableRequest(currentRequest)) { return cloneJson(currentRequest ?? {}); @@ -1136,12 +1349,13 @@ function buildRequestWithSchemaMerge(): any { const authType = ($('authType') as HTMLSelectElement).value; const model: RequestEditorModel = { protocol: _currentProtocol, - method: (methodSelect as HTMLSelectElement).value, + method: (_currentProtocol === 'http' || _currentProtocol === 'graphql') ? (methodSelect as HTMLSelectElement).value : undefined, url: getUrlText(), - params: collectParams(), + params: (_currentProtocol === 'http' || _currentProtocol === 'graphql') ? collectParams() : undefined, headers: collectHeaders(), body: collectBodyModel(), auth: buildAuthData(authType, 'auth'), + runtime: collectRuntimeModel(), settings: { timeout: parseInt($input('settingTimeout').value) || 30000, encodeUrl: $input('settingEncodeUrl').checked, @@ -1234,7 +1448,8 @@ function loadRequest(req: any): void { // Headers $('headersBody').innerHTML = ''; - (details.headers || []).forEach((h: any, index: number) => addHeader(h.name, h.value, h.disabled, index)); + const headerRows = protocol === 'grpc' ? details.metadata : details.headers; + (headerRows || []).forEach((h: any, index: number) => addHeader(h.name, h.value, h.disabled, index)); // Body _selectedBodyVariantIndex = undefined; @@ -1311,6 +1526,7 @@ function loadRequest(req: any): void { // Auth — read from runtime.auth per OpenCollection schema const runtime = currentRequest.runtime || {}; + loadRuntimeEditor(runtime); const auth = runtime.auth; if (auth === 'inherit') { ($('authType') as HTMLSelectElement).value = 'inherit'; @@ -1871,6 +2087,10 @@ $('exportSaveBtn').addEventListener('click', () => { $('addParamBtn').addEventListener('click', () => { addParam(); syncUrlFromParams(); }); $('addHeaderBtn').addEventListener('click', () => addHeader()); $('addFormFieldBtn').addEventListener('click', () => addFormField()); +$('addRuntimeScriptBtn').addEventListener('click', () => { addRuntimeScript('before-request', 'console.log("before request");'); scheduleDocumentUpdate(); }); +$('addRuntimeTestBtn').addEventListener('click', () => { addRuntimeScript('tests', 'test("response is successful", () => assert(response.status < 400));'); scheduleDocumentUpdate(); }); +$('addRuntimeAssertionBtn').addEventListener('click', () => { addRuntimeAssertion(); scheduleDocumentUpdate(); }); +$('addRuntimeActionBtn').addEventListener('click', () => { addRuntimeAction(); scheduleDocumentUpdate(); }); ($('authType') as HTMLSelectElement).innerHTML = authTypeOptionsHtml(true); $('authType').addEventListener('change', () => { onAuthTypeChange(); scheduleDocumentUpdate(); }); $('panel-auth').addEventListener('input', scheduleDocumentUpdate); diff --git a/test/runtimeAuthoringUx.test.ts b/test/runtimeAuthoringUx.test.ts new file mode 100644 index 0000000..80d93f4 --- /dev/null +++ b/test/runtimeAuthoringUx.test.ts @@ -0,0 +1,257 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import Ajv from 'ajv'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { + applyRequestEditorModel, + createRequestEditorModelFromRequest, + type RequestEditorModel, +} from '../src/models/schemaRoundTrip'; +import type { RequestProtocol } from '../src/models/types'; +import { RequestEditorProvider } from '../src/panels/requestPanel'; + +const schema = require('../schema/opencollectionschema.json'); + +const schemaByProtocol: Record = { + http: 'HttpRequest', + graphql: 'GraphQLRequest', + websocket: 'WebSocketRequest', + grpc: 'GrpcRequest', +}; + +const protocolRoots: RequestProtocol[] = ['http', 'graphql', 'websocket', 'grpc']; + +function validateSubschema(protocol: RequestProtocol, data: unknown): void { + const ajv = new Ajv({ allErrors: true, strict: false }); + const defName = schemaByProtocol[protocol]; + const validate = ajv.compile({ + $schema: schema.$schema, + $id: `${schema.$id}#test-runtime-authoring-${defName}`, + $ref: `${schema.$id}#/$defs/${defName}`, + $defs: schema.$defs, + }); + expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true); +} + +function yamlRoundTrip(data: unknown): unknown { + return parseYaml(stringifyYaml(data, { lineWidth: 120 })); +} + +function runtimeFixture() { + return { + variables: [{ name: 'runtimeOwner', value: 'Ada' }], + scripts: [ + { type: 'before-request', code: 'missio.variables.set("trace", "before");' }, + { type: 'tests', code: 'test("ok", () => assert(response.status < 400));', disabled: true }, + ], + assertions: [ + { + expression: 'res.status', + operator: 'equals', + value: '200', + disabled: true, + description: { content: 'Status remains visible', type: 'text/markdown' }, + }, + ], + actions: [ + { + type: 'set-variable', + phase: 'after-response', + selector: { method: 'jsonq', expression: '$.token' }, + variable: { scope: 'runtime', name: 'responseToken' }, + disabled: true, + description: { content: 'Capture token', type: 'text/plain' }, + }, + ], + }; +} + +function requestForProtocol(protocol: RequestProtocol): any { + const runtime = runtimeFixture(); + if (protocol === 'graphql') { + return { + info: { name: 'GraphQL runtime', type: 'graphql' }, + graphql: { + method: 'POST', + url: '{{baseUrl}}/graphql', + headers: [{ name: 'X-Trace', value: '{{trace}}' }], + body: { query: 'query Health { health { status } }', variables: '{"trace":"{{trace}}"}' }, + }, + runtime, + settings: { timeout: 5000 }, + }; + } + if (protocol === 'websocket') { + return { + info: { name: 'WebSocket runtime', type: 'websocket' }, + websocket: { + url: '{{wsBaseUrl}}/ws/auth', + headers: [{ name: 'X-Trace', value: '{{trace}}' }], + message: { type: 'json', data: '{"user":"Ada"}' }, + }, + runtime, + }; + } + if (protocol === 'grpc') { + return { + info: { name: 'gRPC runtime', type: 'grpc' }, + grpc: { + url: '{{grpcBaseUrl}}', + method: 'missio.demo.DemoService/EchoUnary', + methodType: 'unary', + protoFilePath: 'proto/services/missio_demo.proto', + metadata: [{ name: 'x-trace-id', value: '{{trace}}' }], + message: '{"name":"Ada","trace":{"requestId":"{{trace}}"}}', + }, + runtime, + }; + } + return { + info: { name: 'HTTP runtime', type: 'http' }, + http: { + method: 'POST', + url: '{{baseUrl}}/runtime/token', + headers: [{ name: 'Content-Type', value: 'application/json' }], + body: { type: 'json', data: '{"owner":"{{runtimeOwner}}"}' }, + }, + runtime, + settings: { timeout: 5000 }, + }; +} + +describe('OC-110 runtime authoring model', () => { + it('round-trips runtime scripts, assertions, and actions for every request protocol', () => { + for (const protocol of protocolRoots) { + const request = requestForProtocol(protocol); + const model = createRequestEditorModelFromRequest(request); + const updated = applyRequestEditorModel(request, model); + + expect(updated).toEqual(request); + expect(yamlRoundTrip(updated)).toEqual(request); + expect(protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(updated as object, root))).toEqual([protocol]); + validateSubschema(protocol, updated); + } + }); + + it('creates, edits, disables, reorders, and removes managed runtime entries without dropping runtime siblings', () => { + const request = requestForProtocol('http'); + const model = createRequestEditorModelFromRequest(request) as RequestEditorModel; + model.runtime = { + scripts: [ + { + type: 'tests', + code: 'test("edited", () => assert(response.status === 201));', + disabled: true, + originalIndex: 1, + }, + { + type: 'after-response', + code: 'console.log("new after response");', + }, + ], + assertions: [ + { + expression: 'res.body.ok', + operator: 'equals', + value: 'true', + disabled: false, + description: 'Status remains visible', + originalIndex: 0, + }, + ], + actions: [], + }; + + const updated = applyRequestEditorModel(request, model) as any; + + expect(updated.runtime.variables).toEqual(request.runtime.variables); + expect(updated.runtime.auth).toBeUndefined(); + expect(updated.runtime.scripts).toEqual([ + { + type: 'tests', + code: 'test("edited", () => assert(response.status === 201));', + disabled: true, + }, + { + type: 'after-response', + code: 'console.log("new after response");', + }, + ]); + expect(updated.runtime.assertions).toEqual([ + { + expression: 'res.body.ok', + operator: 'equals', + value: 'true', + disabled: false, + description: { content: 'Status remains visible', type: 'text/markdown' }, + }, + ]); + expect(updated.runtime.actions).toEqual([]); + expect(yamlRoundTrip(updated)).toEqual(updated); + validateSubschema('http', updated); + }); + + it('adds runtime authoring edits to gRPC requests without changing method, metadata, or protocol root', () => { + const request = requestForProtocol('grpc'); + const model = createRequestEditorModelFromRequest(request) as RequestEditorModel; + model.runtime = { + scripts: [{ type: 'before-request', code: 'missio.variables.set("grpcEdited", "yes");' }], + assertions: [{ expression: 'res.body.name', operator: 'equals', value: 'Ada' }], + actions: [{ + type: 'set-variable', + phase: 'after-response', + selectorMethod: 'jsonq', + selectorExpression: '$.requestId', + variableScope: 'runtime', + variableName: 'grpcRequestId', + }], + }; + + const updated = applyRequestEditorModel(request, model) as any; + + expect(updated.http).toBeUndefined(); + expect(updated.graphql).toBeUndefined(); + expect(updated.websocket).toBeUndefined(); + expect(updated.grpc.method).toBe(request.grpc.method); + expect(updated.grpc.metadata).toEqual(request.grpc.metadata); + expect(updated.runtime.scripts[0].code).toContain('grpcEdited'); + validateSubschema('grpc', updated); + }); +}); + +describe('OC-110 runtime authoring UI shell', () => { + it('renders request Runtime authoring controls separately from response Runtime results', () => { + const provider = new RequestEditorProvider( + { extensionUri: { fsPath: process.cwd() } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + const html = (provider as any)._getBodyHtml({} as any) as string; + + expect(html).toContain('data-tab="runtime"'); + expect(html).toContain('id="runtimeBadge"'); + expect(html).toContain('id="panel-runtime"'); + expect(html).toContain('id="runtimeScriptsList"'); + expect(html).toContain('id="runtimeAssertionsList"'); + expect(html).toContain('id="runtimeActionsList"'); + expect(html).toContain('id="addRuntimeScriptBtn"'); + expect(html).toContain('id="addRuntimeTestBtn"'); + expect(html).toContain('id="addRuntimeAssertionBtn"'); + expect(html).toContain('id="addRuntimeActionBtn"'); + expect(html).toContain('id="panel-resp-runtime"'); + }); + + it('keeps runtime authoring layout classes available in the request panel stylesheet', () => { + const css = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); + + expect(css).toContain('.runtime-authoring'); + expect(css).toContain('.runtime-editor-row'); + expect(css).toContain('.runtime-row-toolbar'); + expect(css).toContain('.runtime-icon-btn'); + expect(css).toContain('.runtime-list:empty::before'); + }); +}); diff --git a/test/runtimeExecutionService.test.ts b/test/runtimeExecutionService.test.ts index e4e420a..cd2b65a 100644 --- a/test/runtimeExecutionService.test.ts +++ b/test/runtimeExecutionService.test.ts @@ -164,6 +164,40 @@ describe('RuntimeExecutionService lifecycle', () => { expect(response.runtime?.tests[1].message).toMatch(/Code generation from strings disallowed/); }); + it('skips disabled runtime scripts without deleting them from authored requests', async () => { + const service = new RuntimeExecutionService(); + const request: HttpRequest = { + http: { method: 'GET', url: 'http://127.0.0.1/runtime' }, + runtime: { + scripts: [ + { + type: 'before-request', + disabled: true, + code: 'missio.request.setHeader("X-Disabled-Script", "ran");', + }, + { + type: 'tests', + disabled: true, + code: 'test("disabled test", () => assert(false));', + }, + { + type: 'tests', + code: 'test("enabled test", () => assert(response.status === 200));', + }, + ], + }, + }; + + const prepared = await service.prepareHttpRequest(request, makeCollection()); + expect(prepared.request.runtime?.scripts).toEqual(request.runtime?.scripts); + expect(prepared.request.http?.headers?.find(header => header.name === 'X-Disabled-Script')).toBeUndefined(); + + const response = await service.completeHttpRequest(prepared, makeResponse({ ok: true })); + + expect(response.runtime?.success).toBe(true); + expect(response.runtime?.tests.map(test => test.name)).toEqual(['enabled test']); + }); + it('records assertion and action failure diagnostics without hiding the response', async () => { const service = new RuntimeExecutionService(); const request: HttpRequest = { diff --git a/test/validationService.test.ts b/test/validationService.test.ts index 95c1eec..9b4ca59 100644 --- a/test/validationService.test.ts +++ b/test/validationService.test.ts @@ -44,6 +44,7 @@ describe('validationService', () => { writeYaml(rootDir, 'get-users.yml', { info: { name: 'Get users', type: 'http' }, http: { method: 'GET', url: 'https://api.example.com/users' }, + runtime: { scripts: [{ type: 'before-request', code: 'console.log("skip");', disabled: true }] }, }); writeYaml(rootDir, 'graphql-users.yml', { info: { name: 'GraphQL users', type: 'graphql' }, @@ -52,10 +53,12 @@ describe('validationService', () => { url: 'https://api.example.com/graphql', body: { query: 'query Users { users { id } }', variables: '{"limit":10}' }, }, + runtime: { scripts: [{ type: 'tests', code: 'test("skip", () => assert(false));', disabled: true }] }, }); writeYaml(rootDir, 'socket.yml', { info: { name: 'Socket ping', type: 'websocket' }, websocket: { url: 'wss://api.example.com/socket', message: { type: 'text', data: 'ping' } }, + runtime: { scripts: [{ type: 'after-response', code: 'console.log("skip");', disabled: true }] }, }); writeYaml(rootDir, 'grpc-user.yml', { info: { name: 'Get user', type: 'grpc' }, @@ -65,6 +68,7 @@ describe('validationService', () => { methodType: 'unary', message: '{"id":"42"}', }, + runtime: { scripts: [{ type: 'before-request', code: 'console.log("skip");', disabled: true }] }, }); writeYaml(rootDir, 'grpc-upload-users.yml', { info: { name: 'Upload users', type: 'grpc' }, From 28303f4212ba0b47ca2237460990622f8f15033b Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 09:30:25 +1200 Subject: [PATCH 2/9] Record OC-110 completion --- .../AGENT_PROGRESS.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 97dba6b..db8e0fe 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -68,7 +68,7 @@ Use full branch names for stacking existing branches with `but move