diff --git a/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md b/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md index a811854..a1c45ca 100644 --- a/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md +++ b/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md @@ -55,7 +55,7 @@ If an agent host does not inject the project-local skills, tell the agent to rea ## Request Type UX ```text -/goal Complete OC-100 request type UX using $missio-agent-coordination and $missio-editor-schema-implementer without stopping until Bruno/Postman UX benchmarking is recorded, UI request creation supports HTTP/GraphQL/WebSocket/gRPC type selection, the visual editor clearly shows and safely switches request type with preservation/loss confirmation, schema-valid conversion helpers are implemented, AGENT_PROGRESS updates are complete, and complete automated creation, conversion, validation, round-trip, and regression tests are passing. +/goal Complete OC-100 request type UX using $missio-agent-coordination and $missio-editor-schema-implementer without stopping until Bruno/Postman UX benchmarking is recorded, UI request creation supports HTTP/GraphQL/WebSocket/gRPC type selection, the visual editor clearly shows read-only request type identity without offering a saved-request switcher, AGENT_PROGRESS updates are complete, and complete automated creation, validation, round-trip, and regression tests are passing. ``` ## Protocol Runtime Lifecycle diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 40c91db..17195fb 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -64,10 +64,10 @@ Use full branch names for stacking existing branches with `but move ({ + label: choice.label, + description: choice.description, + protocol: choice.protocol, + })), + { placeHolder: 'Select request type' }, + ); + if (!protocolPick) { return; } + + const protocolLabel = requestProtocolLabel(protocolPick.protocol); const name = await vscode.window.showInputBox({ - prompt: 'Request name', - placeHolder: 'get-users', + prompt: `${protocolLabel} request name`, + placeHolder: protocolPick.protocol === 'graphql' + ? 'graphql-health' + : protocolPick.protocol === 'websocket' + ? 'socket-echo' + : protocolPick.protocol === 'grpc' + ? 'echo-unary' + : 'get-users', }); if (!name) { return; } if (!targetDir) { return; } - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const slug = slugifyRequestName(name); const fileName = `${slug}.yml`; const filePath = path.join(targetDir, fileName); - const template: HttpRequest = { - info: { name, type: 'http', seq: 1 }, - http: { - method: 'GET', - url: '{{baseUrl}}/', - headers: [], - params: [], - }, - settings: { - encodeUrl: true, - timeout: 30000, - followRedirects: true, - maxRedirects: 5, - }, - }; + const template = createRequestTemplate(protocolPick.protocol, name); const content = stringifyYaml(template, { lineWidth: 120 }); await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(content, 'utf-8')); diff --git a/src/panels/basePanel.ts b/src/panels/basePanel.ts index d05545c..c353ba1 100644 --- a/src/panels/basePanel.ts +++ b/src/panels/basePanel.ts @@ -420,6 +420,9 @@ export abstract class BaseEditorProvider implements vscode.CustomTextEditorProvi .codicon-folder-library::before { content: '\\ebdf'; } .codicon-folder::before { content: '\\ea83'; } .codicon-globe::before { content: '\\eb01'; } +.codicon-plug::before { content: '\\eb2d'; } +.codicon-radio-tower::before { content: '\\eb34'; } +.codicon-type-hierarchy::before { content: '\\ebb9'; } .codicon-add::before { content: '\\ea60'; } .codicon-desktop-download::before { content: '\\ec74'; } .codicon-trash::before { content: '\\ea81'; } diff --git a/src/panels/requestPanel.ts b/src/panels/requestPanel.ts index 2f6195f..6f45fae 100644 --- a/src/panels/requestPanel.ts +++ b/src/panels/requestPanel.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { parse as parseYaml } from 'yaml'; import type { HttpRequest, OpenCollectionRequest, RequestDefaults, MissioCollection } from '../models/types'; -import { getItemKind, isGraphQLRequest, isHttpRequest, isProtocolRequest, isWebSocketRequest } from '../models/types'; +import { getItemKind, isGraphQLRequest, isGrpcRequest, isHttpRequest, isProtocolRequest, isWebSocketRequest } from '../models/types'; import { requestLog, type ResolvedRequest } from '../services/httpClient'; import type { RequestExecutionService } from '../services/requestExecutionService'; import { exportRequest, findTarget, EXPORT_TARGETS } from '../services/snippetExporter'; @@ -135,13 +135,16 @@ export class RequestEditorProvider extends BaseEditorProvider { ? 'graphql' : isWebSocketRequest(current) ? 'websocket' - : isHttpRequest(current) - ? 'http' - : undefined; + : isGrpcRequest(current) + ? 'grpc' + : isHttpRequest(current) + ? 'http' + : undefined; if (!currentProtocol) return false; if (!isProtocolRequest(next)) return true; if (currentProtocol === 'graphql') return isGraphQLRequest(next); if (currentProtocol === 'websocket') return isWebSocketRequest(next); + if (currentProtocol === 'grpc') return isGrpcRequest(next); return isHttpRequest(next); } @@ -820,8 +823,10 @@ export class RequestEditorProvider extends BaseEditorProvider { - -
+
+ +
+
diff --git a/src/services/requestTemplates.ts b/src/services/requestTemplates.ts new file mode 100644 index 0000000..9605a70 --- /dev/null +++ b/src/services/requestTemplates.ts @@ -0,0 +1,88 @@ +import type { OpenCollectionRequest, RequestProtocol } from '../models/types'; + +export interface RequestProtocolChoice { + protocol: RequestProtocol; + label: string; + description: string; +} + +export const REQUEST_PROTOCOL_CHOICES: RequestProtocolChoice[] = [ + { protocol: 'http', label: 'HTTP', description: 'REST or HTTP API request' }, + { protocol: 'graphql', label: 'GraphQL', description: 'GraphQL query or mutation request' }, + { protocol: 'websocket', label: 'WebSocket', description: 'WebSocket connect and message request' }, + { protocol: 'grpc', label: 'gRPC', description: 'Unary gRPC request with protobuf configuration' }, +]; + +export function requestProtocolLabel(protocol: RequestProtocol): string { + return REQUEST_PROTOCOL_CHOICES.find(choice => choice.protocol === protocol)?.label ?? protocol.toUpperCase(); +} + +export function slugifyRequestName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'request'; +} + +export function createRequestTemplate(protocol: RequestProtocol, name: string, seq = 1): OpenCollectionRequest { + switch (protocol) { + case 'graphql': + return { + info: { name, type: 'graphql', seq }, + graphql: { + method: 'POST', + url: '{{baseUrl}}/graphql', + body: { + query: [ + 'query Example {', + ' health {', + ' status', + ' }', + '}', + ].join('\n'), + variables: '{}', + }, + }, + settings: { + encodeUrl: true, + timeout: 30000, + followRedirects: true, + maxRedirects: 5, + }, + }; + case 'websocket': + return { + info: { name, type: 'websocket', seq }, + websocket: { + url: '{{wsBaseUrl}}/ws/echo', + message: { + type: 'text', + data: 'hello', + }, + }, + }; + case 'grpc': + return { + info: { name, type: 'grpc', seq }, + grpc: { + url: '{{grpcBaseUrl}}', + method: 'package.Service/Method', + methodType: 'unary', + protoFilePath: 'proto/service.proto', + message: '{}', + }, + }; + case 'http': + default: + return { + info: { name, type: 'http', seq }, + http: { + method: 'GET', + url: '{{baseUrl}}/', + }, + settings: { + encodeUrl: true, + timeout: 30000, + followRedirects: true, + maxRedirects: 5, + }, + }; + } +} diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index 0258c98..9b0f815 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -75,21 +75,6 @@ body { position: relative; min-width: 100px; } -.protocol-chip { - min-width: 72px; - background: var(--input-bg); - color: var(--vscode-textLink-foreground, #4fc1ff); - border: 1px solid var(--input-border); - border-radius: 4px; - padding: 6px 10px; - font-size: 14px; - font-weight: 600; - font-family: var(--vscode-editor-font-family, monospace); - align-items: center; - justify-content: center; - line-height: 1.4; - box-sizing: border-box; -} .method-picker .method-select { width: 100%; } @@ -217,13 +202,33 @@ body { position: relative; min-width: 0; } +.protocol-icon { + position: absolute; + left: 10px; + top: 50%; + width: 16px; + height: 16px; + transform: translateY(-50%); + z-index: 101; + pointer-events: none; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + opacity: 0.95; +} +.protocol-icon-http { color: var(--m-protocol-http); } +.protocol-icon-graphql { color: var(--m-protocol-graphql); } +.protocol-icon-websocket { color: var(--m-protocol-websocket); } +.protocol-icon-grpc { color: var(--m-protocol-grpc); } .url-input { width: 100%; height: 100%; background: var(--input-bg); color: var(--input-fg); border: 1px solid var(--input-border); - padding: 6px 10px; + padding: 6px 10px 6px 34px; border-radius: 4px; font-size: 12px; font-family: var(--vscode-editor-font-family, monospace); @@ -773,9 +778,9 @@ td.var-cell .var-overlay { .add-row-btn:hover { border-color: var(--btn-bg); background: rgba(0,120,212,0.05); } /* ── Body Type Pills ────────────────────── */ -.body-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; justify-content: space-between; } +.body-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; justify-content: flex-start; } .body-type-pills { display: flex; border: 1px solid var(--input-border); border-radius: 4px; overflow: hidden; } -.body-toolbar-actions { display: flex; align-items: center; gap: 8px; } +.body-toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } .body-type-pills .pill { padding: 5px 12px; background: transparent; diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index f94fc47..902bfb0 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -78,6 +78,7 @@ import { canFormatRawBody, formatRawBody } from './requestBodyFormatter'; import { applyRequestEditorModel, cloneJson, + detectRequestProtocol, isVisualEditableRequest, type FormFieldEditorRow, type KeyValueEditorRow, @@ -88,7 +89,7 @@ import { // ── Document update scheduling ─────────────────── let _selectedBodyVariantIndex: number | undefined; let _selectedFileVariantIndex: number | undefined; -type PanelProtocol = 'http' | 'graphql' | 'websocket'; +type PanelProtocol = 'http' | 'graphql' | 'websocket' | 'grpc'; let _currentProtocol: PanelProtocol = 'http'; function scheduleDocumentUpdate(): void { @@ -628,45 +629,60 @@ function setProtocolUi(protocol: PanelProtocol): void { _currentProtocol = protocol; const isGraphQL = protocol === 'graphql'; const isWebSocket = protocol === 'websocket'; + const isGrpc = protocol === 'grpc'; const methodPicker = $('methodPicker') as HTMLElement; - const protocolChip = $('protocolChip') as HTMLElement; - methodPicker.style.display = isWebSocket ? 'none' : ''; - protocolChip.style.display = isWebSocket ? 'flex' : 'none'; - protocolChip.textContent = 'WS'; - - setRequestTabVisible('params', !isWebSocket); - setRequestTabVisible('settings', !isWebSocket); - setRequestTabVisible('export', !isWebSocket); + const protocolIcon = $('protocolIcon') as HTMLElement; + const protocolLabels: Record = { + http: 'HTTP', + graphql: 'GraphQL', + websocket: 'WebSocket', + grpc: 'gRPC', + }; + const protocolIcons: Record = { + http: 'globe', + graphql: 'type-hierarchy', + websocket: 'plug', + grpc: 'radio-tower', + }; + methodPicker.style.display = (isWebSocket || isGrpc) ? 'none' : ''; + protocolIcon.className = 'codicon codicon-' + protocolIcons[protocol] + ' protocol-icon protocol-icon-' + protocol; + protocolIcon.dataset.protocol = protocol; + protocolIcon.setAttribute('aria-label', protocolLabels[protocol] + ' request type'); + protocolIcon.setAttribute('title', protocolLabels[protocol] + ' request type'); + + setRequestTabVisible('params', !isWebSocket && !isGrpc); + setRequestTabVisible('settings', !isWebSocket && !isGrpc); + setRequestTabVisible('export', !isWebSocket && !isGrpc); const bodyTab = document.querySelector('#reqTabs [data-tab="body"]'); - if (bodyTab) bodyTab.textContent = isWebSocket ? 'Message' : 'Body'; - if (isWebSocket && ['params', 'settings', 'export'].some(tabId => document.getElementById('panel-' + tabId)?.classList.contains('active'))) { + if (bodyTab) bodyTab.textContent = (isWebSocket || isGrpc) ? 'Message' : 'Body'; + if ((isWebSocket || isGrpc) && ['params', 'settings', 'export'].some(tabId => document.getElementById('panel-' + tabId)?.classList.contains('active'))) { switchTab($('reqTabs'), 'body', reqPanelIds); } - $('bodyTypePills').style.display = (isGraphQL || isWebSocket) ? 'none' : 'flex'; + $('bodyTypePills').style.display = (isGraphQL || isWebSocket || isGrpc) ? 'none' : 'flex'; const bodyData = $('bodyData') as HTMLTextAreaElement; bodyData.placeholder = isGraphQL ? 'query Example { viewer { id name } }' - : isWebSocket + : isWebSocket || isGrpc ? 'Message payload' : ''; (document.getElementById('url') as HTMLElement).setAttribute( 'data-placeholder', - isWebSocket ? '{{wsBaseUrl}}/echo' : '{{baseUrl}}/api/endpoint', + isWebSocket ? '{{wsBaseUrl}}/echo' : isGrpc ? '{{grpcBaseUrl}}' : '{{baseUrl}}/api/endpoint', ); if (!isSending) { - $('sendBtn').textContent = isWebSocket ? 'Connect + Send' : 'Send'; + $('sendBtn').textContent = isWebSocket ? 'Connect + Send' : isGrpc ? 'Invoke' : 'Send'; } - $('saveExampleBtn').style.display = isWebSocket ? 'none' : ''; + $('saveExampleBtn').style.display = (isWebSocket || isGrpc) ? 'none' : ''; $('refreshOAuthRetryBtn').style.display = 'none'; if (isGraphQL) { setCurrentLang('text'); ($('bodyLangMode') as HTMLSelectElement).value = currentLang; setBodyType('raw'); - } else if (isWebSocket) { - setCurrentLang('text'); + } else if (isWebSocket || isGrpc) { + setCurrentLang(isGrpc ? 'json' : 'text'); ($('bodyLangMode') as HTMLSelectElement).value = currentLang; setBodyType('raw'); } else { @@ -1166,7 +1182,7 @@ function setSendingState(sending: boolean): void { } else { btn.classList.remove('sending'); btn.classList.remove('btn-cancel'); - btn.textContent = _currentProtocol === 'websocket' ? 'Connect + Send' : 'Send'; + btn.textContent = _currentProtocol === 'websocket' ? 'Connect + Send' : _currentProtocol === 'grpc' ? 'Invoke' : 'Send'; btn.disabled = false; } } @@ -1186,14 +1202,20 @@ function saveRequest(): void { function loadRequest(req: any): void { setCurrentRequest(req); $('exampleIndicator').style.display = 'none'; - const protocol: PanelProtocol = req.websocket ? 'websocket' : req.graphql ? 'graphql' : 'http'; + const detectedProtocol = detectRequestProtocol(req); + const protocol: PanelProtocol = + detectedProtocol === 'graphql' || detectedProtocol === 'websocket' || detectedProtocol === 'grpc' + ? detectedProtocol + : 'http'; setProtocolUi(protocol); const details = protocol === 'websocket' ? (req.websocket || {}) : protocol === 'graphql' ? (req.graphql || {}) - : (req.http || {}); - if (protocol !== 'websocket') { + : protocol === 'grpc' + ? (req.grpc || {}) + : (req.http || {}); + if (protocol === 'http' || protocol === 'graphql') { (methodSelect as HTMLSelectElement).value = (details.method || (protocol === 'graphql' ? 'POST' : 'GET')).toUpperCase(); updateMethodColor(); } @@ -1218,13 +1240,13 @@ function loadRequest(req: any): void { _selectedBodyVariantIndex = undefined; _selectedFileVariantIndex = undefined; ($('graphqlVariablesData') as HTMLTextAreaElement).value = ''; - const requestBody = protocol === 'websocket' ? details.message : details.body; + const requestBody = (protocol === 'websocket' || protocol === 'grpc') ? details.message : details.body; if (requestBody) { _selectedBodyVariantIndex = Array.isArray(requestBody) ? Math.max(0, requestBody.findIndex((v: any) => v.selected)) : undefined; const body = Array.isArray(requestBody) - ? (protocol === 'websocket' + ? (protocol === 'websocket' || protocol === 'grpc' ? requestBody[_selectedBodyVariantIndex ?? 0]?.message : requestBody[_selectedBodyVariantIndex ?? 0]?.body) : requestBody; @@ -1245,6 +1267,13 @@ function loadRequest(req: any): void { updateBodyFormatterState(); ($('bodyData') as HTMLTextAreaElement).value = body.data ?? ''; syncHighlight(); + } else if (protocol === 'grpc') { + setBodyType('raw'); + setCurrentLang('json'); + ($('bodyLangMode') as HTMLSelectElement).value = currentLang; + updateBodyFormatterState(); + ($('bodyData') as HTMLTextAreaElement).value = typeof body === 'string' ? body : (body.message ?? ''); + syncHighlight(); } else if (body.type === 'form-urlencoded' || body.type === 'multipart-form') { setBodyType(body.type); $('bodyFormBody').innerHTML = ''; @@ -1271,7 +1300,7 @@ function loadRequest(req: any): void { setBodyType('raw'); ($('bodyData') as HTMLTextAreaElement).value = ''; syncHighlight(); - } else if (protocol === 'websocket') { + } else if (protocol === 'websocket' || protocol === 'grpc') { setBodyType('raw'); ($('bodyData') as HTMLTextAreaElement).value = ''; syncHighlight(); diff --git a/src/webview/theme.css b/src/webview/theme.css index cdb8d46..36d5742 100644 --- a/src/webview/theme.css +++ b/src/webview/theme.css @@ -55,6 +55,12 @@ --m-method-head: var(--vscode-missio-methodHead, #9cd8a4); --m-method-options: var(--vscode-missio-methodOptions, #d16faa); + /* Request protocols */ + --m-protocol-http: var(--vscode-missio-protocolHttp, #8aabeb); + --m-protocol-graphql: var(--vscode-missio-protocolGraphql, #d16faa); + --m-protocol-websocket: var(--vscode-missio-protocolWebsocket, #4ec9b0); + --m-protocol-grpc: var(--vscode-missio-protocolGrpc, #b9aad9); + /* Syntax tokens */ --m-tk-key: #9cdcfe; --m-tk-str: #ce9178; @@ -164,6 +170,12 @@ body[data-vscode-theme-kind="vscode-high-contrast-light"] { --m-method-head: var(--vscode-missio-methodHead, #2e7d32); --m-method-options: var(--vscode-missio-methodOptions, #ad1457); + /* Request protocols */ + --m-protocol-http: var(--vscode-missio-protocolHttp, #1565c0); + --m-protocol-graphql: var(--vscode-missio-protocolGraphql, #ad1457); + --m-protocol-websocket: var(--vscode-missio-protocolWebsocket, #00796b); + --m-protocol-grpc: var(--vscode-missio-protocolGrpc, #6a1b9a); + /* Variable highlighting */ --m-var-color: #795e26; --m-var-bg: rgba(121,94,38,0.08); diff --git a/test/mocks/vscode.ts b/test/mocks/vscode.ts index fdd9b59..8d2af10 100644 --- a/test/mocks/vscode.ts +++ b/test/mocks/vscode.ts @@ -42,6 +42,7 @@ export const window = { showInformationMessage: async () => {}, showWarningMessage: async () => {}, showErrorMessage: async () => {}, + showQuickPick: async () => undefined, showInputBox: async () => undefined, createOutputChannel: () => ({ appendLine: () => {}, dispose: () => {} }), createStatusBarItem: () => ({ show: () => {}, hide: () => {}, dispose: () => {} }), diff --git a/test/requestTypeUx.test.ts b/test/requestTypeUx.test.ts new file mode 100644 index 0000000..c5cc4b8 --- /dev/null +++ b/test/requestTypeUx.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import Ajv from 'ajv'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import * as vscode from 'vscode'; +import { + applyRequestEditorModel, + createRequestEditorModelFromRequest, +} from '../src/models/schemaRoundTrip'; +import type { MissioCollection, RequestProtocol } from '../src/models/types'; +import { registerRequestCommands } from '../src/commands/requestCommands'; +import { RequestEditorProvider } from '../src/panels/requestPanel'; +import { + createRequestTemplate, + REQUEST_PROTOCOL_CHOICES, + requestProtocolLabel, + slugifyRequestName, +} from '../src/services/requestTemplates'; + +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-${defName}`, + $ref: `${schema.$id}#/$defs/${defName}`, + $defs: schema.$defs, + }); + expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true); +} + +function makeCollection(rootDir: string): MissioCollection { + return { + id: path.join(rootDir, 'opencollection.yml'), + filePath: path.join(rootDir, 'opencollection.yml'), + rootDir, + data: { + opencollection: '1.0.0', + info: { name: 'Request Type UX Test' }, + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('request type UX templates', () => { + it('builds schema-valid starter requests for every supported protocol', () => { + for (const choice of REQUEST_PROTOCOL_CHOICES) { + const request = createRequestTemplate(choice.protocol, `${choice.label} Example`); + const yamlRoundTrip = parseYaml(stringifyYaml(request, { lineWidth: 120 })); + + expect((request as any).info).toMatchObject({ + name: `${choice.label} Example`, + type: choice.protocol, + }); + expect(protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(request, root))).toEqual([choice.protocol]); + expect(yamlRoundTrip).toEqual(request); + validateSubschema(choice.protocol, request); + } + }); + + it('round-trips created starters through the visual editor model without stale protocol roots', () => { + for (const choice of REQUEST_PROTOCOL_CHOICES) { + const request = createRequestTemplate(choice.protocol, `${choice.label} Round Trip`); + const model = createRequestEditorModelFromRequest(request); + const updated = applyRequestEditorModel(request, model); + + expect(updated).toEqual(request); + expect(protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(updated as object, root))).toEqual([choice.protocol]); + validateSubschema(choice.protocol, updated); + } + }); + + it('uses stable labels and filesystem-safe slugs for request creation', () => { + expect(requestProtocolLabel('grpc')).toBe('gRPC'); + expect(slugifyRequestName('Echo unary / metadata')).toBe('echo-unary-metadata'); + expect(slugifyRequestName('***')).toBe('request'); + }); +}); + +describe('new request command protocol selection', () => { + it('writes a schema-valid gRPC starter selected from the creation picker', async () => { + const rootDir = path.join(process.cwd(), 'tmp-request-type-ux'); + const handlers = new Map unknown>(); + vi.spyOn(vscode.commands, 'registerCommand').mockImplementation((name: string, callback: (...args: any[]) => unknown) => { + handlers.set(name, callback); + return { dispose: () => {} } as any; + }); + vi.spyOn(vscode.window, 'showQuickPick').mockResolvedValue({ + label: 'gRPC', + description: 'Unary gRPC request with protobuf configuration', + protocol: 'grpc', + } as any); + vi.spyOn(vscode.window, 'showInputBox').mockResolvedValue('Echo unary / metadata'); + const writeFile = vi.spyOn(vscode.workspace.fs, 'writeFile').mockResolvedValue(undefined as any); + const open = vi.spyOn(RequestEditorProvider, 'open').mockResolvedValue(undefined); + + registerRequestCommands({ + collectionService: { + getCollections: () => [makeCollection(rootDir)], + loadRequestFile: vi.fn(), + }, + environmentService: {}, + httpClient: {}, + requestExecutionService: { cancelAll: vi.fn() }, + responseProvider: {}, + collectionTreeProvider: {}, + extensionContext: {}, + } as any); + + const newRequest = handlers.get('missio.newRequest'); + expect(newRequest).toBeDefined(); + await newRequest?.({ collection: { rootDir } }); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ label: 'HTTP', protocol: 'http' })]), + { placeHolder: 'Select request type' }, + ); + expect(writeFile).toHaveBeenCalledOnce(); + const [uri, content] = writeFile.mock.calls[0]; + expect(uri.fsPath).toBe(path.join(rootDir, 'echo-unary-metadata.yml')); + const request = parseYaml(Buffer.from(content as Uint8Array).toString('utf-8')); + expect(request.info.type).toBe('grpc'); + expect(request.grpc).toMatchObject({ + url: '{{grpcBaseUrl}}', + methodType: 'unary', + protoFilePath: 'proto/service.proto', + }); + expect(request.http).toBeUndefined(); + validateSubschema('grpc', request); + expect(open).toHaveBeenCalledWith(path.join(rootDir, 'echo-unary-metadata.yml')); + }); +}); + +describe('request editor protocol identity guard', () => { + it('allows same-protocol gRPC payloads but rejects protocol switches', () => { + const provider = new RequestEditorProvider( + { extensionUri: { fsPath: process.cwd() } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const current = createRequestTemplate('grpc', 'Echo unary'); + + expect((provider as any)._canApplyRequestEdit(current, createRequestTemplate('grpc', 'Echo unary edited'))).toBe(true); + expect((provider as any)._canApplyRequestEdit(current, createRequestTemplate('http', 'Echo unary'))).toBe(false); + }); + + it('renders a read-only protocol icon inside the request URL field', () => { + 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).toMatch(/
[\s\S]*id="protocolIcon"[\s\S]*id="url"/); + expect(html).toContain('id="protocolIcon"'); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="HTTP request type"'); + expect(html).not.toContain('protocol-chip'); + expect(html).not.toContain('id="protocolChip"'); + expect(html).not.toContain('id="requestTypeSwitcher"'); + }); + + it('defines request protocol colors in centralized theme surfaces', () => { + const themeCss = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'theme.css'), 'utf8'); + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const contributedColorIds = new Set((pkg.contributes.colors as Array<{ id: string }>).map(color => color.id)); + + for (const id of ['Http', 'Graphql', 'Websocket', 'Grpc']) { + expect(themeCss).toContain(`--m-protocol-${id.toLowerCase()}`); + expect(themeCss).toContain(`--vscode-missio-protocol${id}`); + expect(contributedColorIds.has(`missio.protocol${id}`)).toBe(true); + } + }); + + it('keeps request body formatting actions right aligned when body type pills are hidden', () => { + const requestPanelCss = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); + + expect(requestPanelCss).toMatch(/\.body-toolbar\s*\{[^}]*justify-content:\s*flex-start;/); + expect(requestPanelCss).toMatch(/\.body-toolbar-actions\s*\{[^}]*margin-left:\s*auto;/); + }); +});