diff --git a/.vscode/launch.json b/.vscode/launch.json index 71e68a26895..31cbe3d008c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -290,7 +290,7 @@ "preLaunchTask": "npm: watch-apk" }, { - "name": "HTTP Client Extension", + "name": "Hurl Client Extension", "type": "extensionHost", "request": "launch", "debugWebviews": true, diff --git a/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json index 0cff0c073d9..c3c2bf78ce6 100644 --- a/common/config/rush/pnpm-config.json +++ b/common/config/rush/pnpm-config.json @@ -95,7 +95,8 @@ "@codemirror/view": "6.38.8", "@codemirror/lint": "6.8.5", "@codemirror/state": "6.5.2", - "qs": ">=6.15.2", + "qs": ">=6.16.0", + "fast-uri": "^3.1.6", "@babel/core": ">=7.29.7", "dompurify": ">=3.4.12", "js-yaml": "4.3.1", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 08a876f8fd6..6da00e1c82e 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -11,7 +11,8 @@ overrides: '@codemirror/view': 6.38.8 '@codemirror/lint': 6.8.5 '@codemirror/state': 6.5.2 - qs: '>=6.15.2' + qs: '>=6.16.0' + fast-uri: ^3.1.6 '@babel/core': '>=7.29.7' dompurify: '>=3.4.12' js-yaml: 4.3.1 @@ -7572,8 +7573,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-xml-builder@1.1.7: resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} @@ -10377,8 +10378,8 @@ packages: (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} querystringify@2.2.0: @@ -19655,7 +19656,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -20014,7 +20015,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 @@ -20027,7 +20028,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -21460,7 +21461,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.13 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.2 @@ -21493,7 +21494,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -21534,7 +21535,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.7: {} fast-xml-builder@1.1.7: dependencies: @@ -25098,7 +25099,7 @@ snapshots: q@1.5.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -25610,7 +25611,7 @@ snapshots: mime-types: 2.1.35 oauth-sign: 0.9.0 performance-now: 2.1.0 - qs: 6.15.3 + qs: 6.16.0 safe-buffer: 5.2.1 tough-cookie: 2.5.0 tunnel-agent: 0.6.0 @@ -27132,7 +27133,7 @@ snapshots: typed-rest-client@1.8.11: dependencies: - qs: 6.15.3 + qs: 6.16.0 tunnel: 0.0.6 underscore: 1.13.8 @@ -27207,7 +27208,7 @@ snapshots: union@0.5.0: dependencies: - qs: 6.15.3 + qs: 6.16.0 unique-filename@2.0.1: dependencies: @@ -27349,7 +27350,7 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.15.3 + qs: 6.16.0 use-callback-ref@1.3.3(@types/react@18.2.0)(react@18.2.0): dependencies: diff --git a/workspaces/api-tryit/hurl-parser/src/hurl-collection-file.ts b/workspaces/api-tryit/hurl-parser/src/hurl-collection-file.ts index eb5073a6d72..51e8ece8e02 100644 --- a/workspaces/api-tryit/hurl-parser/src/hurl-collection-file.ts +++ b/workspaces/api-tryit/hurl-parser/src/hurl-collection-file.ts @@ -158,23 +158,51 @@ export function splitHurlRequestBlocks(content: string): string[] { return parseHurlDocument(content).blocks.map(block => block.text); } +export interface HurlDocumentBoundary { + /** Index into the `blocks` array passed to composeHurlDocumentWithBoundaries. */ + sourceIndex: number; + startLine: number; + endLine: number; +} + +/** + * Like composeHurlDocument, but also reports the line range each surviving + * block occupies in the combined document. Blocks that are empty after + * trimming are dropped, exactly as composeHurlDocument drops them, and are + * simply absent from `boundaries` - callers that need to correlate back to + * their original block list should use `sourceIndex`, not array position. + */ +export function composeHurlDocumentWithBoundaries(blocks: string[]): { document: string; boundaries: HurlDocumentBoundary[] } { + const boundaries: HurlDocumentBoundary[] = []; + const survivingBlocks: string[] = []; + let currentLine = 1; + + blocks.forEach((block, sourceIndex) => { + const normalized = normalizeHurlLineEndings(block).trim(); + if (!normalized) { + return; + } + const lineCount = normalized.split('\n').length; + boundaries.push({ sourceIndex, startLine: currentLine, endLine: currentLine + lineCount - 1 }); + survivingBlocks.push(normalized); + currentLine += lineCount + 1; // account for the blank separator line + }); + + const document = survivingBlocks.length > 0 ? `${survivingBlocks.join('\n\n').trimEnd()}\n` : ''; + return { document, boundaries }; +} + export function composeHurlDocument(header: string, blocks: string[]): string { const normalizedHeader = normalizeHurlLineEndings(header).trim(); - const normalizedBlocks = blocks - .map(block => normalizeHurlLineEndings(block).trim()) - .filter(block => block.length > 0); + const { document: blocksDocument } = composeHurlDocumentWithBoundaries(blocks); - const sections: string[] = []; - if (normalizedHeader) { - sections.push(normalizedHeader); + if (!normalizedHeader) { + return blocksDocument; } - sections.push(...normalizedBlocks); - - if (sections.length === 0) { - return ''; + if (!blocksDocument) { + return `${normalizedHeader}\n`; } - - return `${sections.join('\n\n').trimEnd()}\n`; + return `${normalizedHeader}\n\n${blocksDocument}`; } export function upsertCollectionNameInHurl(content: string, collectionName: string): string { diff --git a/workspaces/api-tryit/hurl-parser/tests/hurl-parser.test.ts b/workspaces/api-tryit/hurl-parser/tests/hurl-parser.test.ts index 1cf55a69580..cbcf98768f3 100644 --- a/workspaces/api-tryit/hurl-parser/tests/hurl-parser.test.ts +++ b/workspaces/api-tryit/hurl-parser/tests/hurl-parser.test.ts @@ -23,7 +23,7 @@ import { parseHurlCollection, } from '../src'; import type { ApiCollection } from '@wso2/api-tryit-core'; -import { parseHurlDocument } from '../src'; +import { composeHurlDocument, composeHurlDocumentWithBoundaries, parseHurlDocument } from '../src'; // verify that the utility for splitting Hurl documents is available @@ -53,6 +53,97 @@ describe('parseHurlDocument helper', () => { }); }); +describe('composeHurlDocument for combining notebook cells into one Chained Run', () => { + it('combines separately-authored entry sources into one document that reparses in the same order', () => { + // Mirrors the marketplace review's exact scenario: a GET that captures + // api_key, followed by an entry that uses {{api_key}} - each string here + // stands in for one notebook cell's own text. + const capturingCell = [ + 'GET http://localhost:3333', + 'HTTP 200', + '[Captures]', + 'api_key: jsonpath "$.api_key"', + ].join('\n'); + const dependentCell = [ + 'GET http://localhost:3333/protected', + 'Authorization: Bearer {{api_key}}', + 'HTTP 200', + ].join('\n'); + + const combined = composeHurlDocument('', [capturingCell, dependentCell]); + const { blocks } = parseHurlDocument(combined); + + expect(blocks).toHaveLength(2); + expect(blocks[0].text).toContain('[Captures]'); + expect(blocks[0].text).toContain('api_key: jsonpath "$.api_key"'); + expect(blocks[1].text).toContain('{{api_key}}'); + expect(blocks[1].method).toBe('GET'); + expect(blocks[1].url).toBe('http://localhost:3333/protected'); + }); + + it('preserves cell order for more than two combined cells', () => { + const cells = [ + 'GET https://example.com/one\nHTTP 200', + 'GET https://example.com/two\nHTTP 200', + 'GET https://example.com/three\nHTTP 200', + ]; + + const combined = composeHurlDocument('', cells); + const { blocks } = parseHurlDocument(combined); + + expect(blocks.map(b => b.url)).toEqual([ + 'https://example.com/one', + 'https://example.com/two', + 'https://example.com/three', + ]); + }); + + it('drops cells that are blank after trimming rather than producing an empty block', () => { + const combined = composeHurlDocument('', ['GET https://example.com/one\nHTTP 200', ' ', '']); + const { blocks } = parseHurlDocument(combined); + + expect(blocks).toHaveLength(1); + }); +}); + +describe('composeHurlDocumentWithBoundaries', () => { + it('reports the line range each block occupies in the combined document', () => { + const blocks = ['GET https://example.com/one\nHTTP 200', 'GET https://example.com/two\nHTTP 200']; + const { document, boundaries } = composeHurlDocumentWithBoundaries(blocks); + + expect(document.split('\n')).toEqual([ + 'GET https://example.com/one', 'HTTP 200', '', 'GET https://example.com/two', 'HTTP 200', '' + ]); + expect(boundaries).toEqual([ + { sourceIndex: 0, startLine: 1, endLine: 2 }, + { sourceIndex: 1, startLine: 4, endLine: 5 } + ]); + }); + + it('leaves a gap in source indices for blocks dropped as blank, without shifting later boundaries', () => { + // Mirrors the notebook header-cell bug: a comment-only block that + // survives (it's non-blank, just has no request) still gets its own + // boundary and line range - callers correlate back to their original + // cell list via sourceIndex, not array position. + const blocks = [ + 'GET https://example.com/one\nHTTP 200', + '# just a comment, no request', + 'GET https://example.com/two\nHTTP 200' + ]; + const { boundaries } = composeHurlDocumentWithBoundaries(blocks); + + expect(boundaries.map(b => b.sourceIndex)).toEqual([0, 1, 2]); + expect(boundaries[1].startLine).toBe(4); + expect(boundaries[2].startLine).toBe(6); + }); + + it('drops genuinely blank blocks entirely, leaving a gap in sourceIndex', () => { + const blocks = ['GET https://example.com/one\nHTTP 200', ' ', 'GET https://example.com/two\nHTTP 200']; + const { boundaries } = composeHurlDocumentWithBoundaries(blocks); + + expect(boundaries.map(b => b.sourceIndex)).toEqual([0, 2]); + }); +}); describe('parseHurlCollection', () => { it('parses a single Hurl request into an ApiCollection model', () => { diff --git a/workspaces/api-tryit/hurl-runner/src/hurl-runner.ts b/workspaces/api-tryit/hurl-runner/src/hurl-runner.ts index 3a546094c57..4264da84f2c 100644 --- a/workspaces/api-tryit/hurl-runner/src/hurl-runner.ts +++ b/workspaces/api-tryit/hurl-runner/src/hurl-runner.ts @@ -407,6 +407,12 @@ export class HurlRunnerImpl implements HurlRunner { if (options.includeResponseOutput) { args.push('-i'); } + // Order matters: later --variables-file entries override earlier ones for + // values they both define, so callers pass the shared file before any + // per-file override. + for (const variablesFilePath of options.variablesFilePaths || []) { + args.push('--variables-file', variablesFilePath); + } if (options.insecure) { args.push('-k'); } @@ -416,6 +422,9 @@ export class HurlRunnerImpl implements HurlRunner { for (const [key, value] of Object.entries(options.variables || {})) { args.push('--variable', `${key}=${value}`); } + if (options.extraArgs && options.extraArgs.length > 0) { + args.push(...options.extraArgs); + } return args; } diff --git a/workspaces/api-tryit/hurl-runner/src/report-parser.ts b/workspaces/api-tryit/hurl-runner/src/report-parser.ts index 76235758a80..367c681a56f 100644 --- a/workspaces/api-tryit/hurl-runner/src/report-parser.ts +++ b/workspaces/api-tryit/hurl-runner/src/report-parser.ts @@ -706,6 +706,70 @@ function deriveFileStatus(execResult: ProcessExecResult, report?: GenericReport, return 'passed'; } +export interface CellBoundary { + startLine: number; + endLine: number; +} + +export interface CellRunOutcome { + entries: HurlEntryResult[]; +} + +/** + * Maps a HurlFileResult produced by a combined, multi-entry run back onto + * one outcome per submitted cell boundary, using each entry's own `line` + * rather than array position. Index-based zipping breaks as soon as any + * cell produces zero entries (e.g. a comment-only cell with no request) or + * more than one (a cell containing multiple requests) - line-range matching + * handles both without shifting every cell after it. A cell whose range + * matches no entry at all is reported with an empty `entries` array, which + * covers both "this cell has no request" and "hurl stopped before reaching + * it" - the caller distinguishes those by inspecting the cell's own source. + * + * An entry whose `line` could not be resolved at all still ran, so it is + * handed to the next boundary no line-matched entry claimed (hurl reports + * entries in file order). Dropping it would report a request that really + * executed as "not run", which misleads far worse than an approximate + * attribution does. + */ +export function mapFileResultToCellOutcomes(fileResult: HurlFileResult, boundaries: CellBoundary[]): CellRunOutcome[] { + const outcomes: CellRunOutcome[] = boundaries.map(() => ({ entries: [] })); + const unplaced: HurlEntryResult[] = []; + + for (const entry of fileResult.entries) { + const line = entry.line; + const boundaryIndex = typeof line === 'number' + ? boundaries.findIndex(boundary => line >= boundary.startLine && line <= boundary.endLine) + : -1; + + if (boundaryIndex >= 0) { + outcomes[boundaryIndex].entries.push(entry); + } else { + unplaced.push(entry); + } + } + + let cursor = 0; + for (let i = 0; i < unplaced.length; i++) { + while (cursor < outcomes.length && outcomes[cursor].entries.length > 0) { + cursor++; + } + if (cursor >= outcomes.length) { + // Every boundary is already claimed. Rather than discard the + // remainder - which would hide requests that really executed - + // attach them to the last boundary so they still surface. + if (outcomes.length > 0) { + outcomes[outcomes.length - 1].entries.push(...unplaced.slice(i)); + } + break; + } + outcomes[cursor].entries.push(unplaced[i]); + cursor++; + } + + return outcomes; +} + export async function parseFileResult(context: ParseContext): Promise { const durationMs = context.finishedAt.getTime() - context.startedAt.getTime(); let report: GenericReport | undefined; diff --git a/workspaces/api-tryit/hurl-runner/src/types.ts b/workspaces/api-tryit/hurl-runner/src/types.ts index db37a666e9d..592306f8625 100644 --- a/workspaces/api-tryit/hurl-runner/src/types.ts +++ b/workspaces/api-tryit/hurl-runner/src/types.ts @@ -36,6 +36,8 @@ export interface HurlRunOptions { timeoutMs?: number; env?: Record; variables?: Record; + variablesFilePaths?: string[]; + extraArgs?: string[]; fileRoot?: string; insecure?: boolean; followRedirects?: boolean; diff --git a/workspaces/api-tryit/hurl-runner/tests/hurl-runner.test.ts b/workspaces/api-tryit/hurl-runner/tests/hurl-runner.test.ts index 9822357b785..50d411df8b8 100644 --- a/workspaces/api-tryit/hurl-runner/tests/hurl-runner.test.ts +++ b/workspaces/api-tryit/hurl-runner/tests/hurl-runner.test.ts @@ -294,6 +294,70 @@ describe('HurlRunnerImpl', () => { expect(hurlCall?.args).not.toContain('--continue-on-error'); }); + it('appends --variables-file for each configured path, shared before per-file override', async () => { + const collection = await createCollection(['variables-file.hurl']); + createdDirs.push(collection.root); + const scenarios = new Map([ + [collection.files[0], { report: buildPassReport('variables-file') }] + ]); + const adapter = new MockProcessAdapter(scenarios); + const runner = createRunner(adapter); + + await runner.run( + { collectionPath: collection.root }, + { parallelism: 1, variablesFilePaths: ['/vars/shared.vars', '/vars/per-file.vars'] } + ); + + const hurlCall = adapter.calls.find(call => call.args.includes('--report-json')); + expect(hurlCall).toBeDefined(); + const args = hurlCall!.args; + const sharedIndex = args.indexOf('/vars/shared.vars'); + const overrideIndex = args.indexOf('/vars/per-file.vars'); + expect(args).toEqual(expect.arrayContaining(['--variables-file', '/vars/shared.vars', '--variables-file', '/vars/per-file.vars'])); + expect(sharedIndex).toBeGreaterThan(-1); + expect(overrideIndex).toBeGreaterThan(sharedIndex); + }); + + it('puts --variables-file entries before -k/-L, not after', async () => { + const collection = await createCollection(['variables-file-order.hurl']); + createdDirs.push(collection.root); + const scenarios = new Map([ + [collection.files[0], { report: buildPassReport('variables-file-order') }] + ]); + const adapter = new MockProcessAdapter(scenarios); + const runner = createRunner(adapter); + + await runner.run( + { collectionPath: collection.root }, + { parallelism: 1, variablesFilePaths: ['/vars/shared.vars'], insecure: true, followRedirects: true } + ); + + const hurlCall = adapter.calls.find(call => call.args.includes('--report-json')); + expect(hurlCall).toBeDefined(); + const args = hurlCall!.args; + expect(args.indexOf('--variables-file')).toBeLessThan(args.indexOf('-k')); + expect(args.indexOf('--variables-file')).toBeLessThan(args.indexOf('-L')); + }); + + it('appends extraArgs verbatim at the end of the argument list', async () => { + const collection = await createCollection(['extra-args.hurl']); + createdDirs.push(collection.root); + const scenarios = new Map([ + [collection.files[0], { report: buildPassReport('extra-args') }] + ]); + const adapter = new MockProcessAdapter(scenarios); + const runner = createRunner(adapter); + + await runner.run( + { collectionPath: collection.root }, + { parallelism: 1, extraArgs: ['--max-redirs', '3', '--retry'] } + ); + + const hurlCall = adapter.calls.find(call => call.args.includes('--report-json')); + expect(hurlCall).toBeDefined(); + expect(hurlCall!.args.slice(-3)).toEqual(['--max-redirs', '3', '--retry']); + }); + it('runStream emits progress events in expected order', async () => { const collection = await createCollection(['one.hurl', 'two.hurl']); createdDirs.push(collection.root); diff --git a/workspaces/api-tryit/hurl-runner/tests/report-parser.test.ts b/workspaces/api-tryit/hurl-runner/tests/report-parser.test.ts index 0584f4d5d40..492a5dc72bb 100644 --- a/workspaces/api-tryit/hurl-runner/tests/report-parser.test.ts +++ b/workspaces/api-tryit/hurl-runner/tests/report-parser.test.ts @@ -19,8 +19,9 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { parseFileResult } from '../src/report-parser'; +import { mapFileResultToCellOutcomes, parseFileResult } from '../src/report-parser'; import { ProcessExecResult } from '../src/process-adapter'; +import { HurlFileResult } from '../src/types'; function makeExecResult(overrides: Partial = {}): ProcessExecResult { return { @@ -481,3 +482,142 @@ describe('parseFileResult', () => { expect(parsed.assertions).toHaveLength(3); }); }); + +describe('mapFileResultToCellOutcomes', () => { + // Entries at lines 1, 5, 9 - matching three 3-line blocks joined with a + // blank separator line each (block, blank, block, blank, block). + function makeFileResult(entryLines: number[]): HurlFileResult { + return { + filePath: '/tmp/combined.hurl', + status: 'passed', + startedAt: '2026-02-23T00:00:00.000Z', + finishedAt: '2026-02-23T00:00:00.010Z', + durationMs: 10, + entries: entryLines.map((line, index) => ({ + name: `Entry ${index + 1}`, + status: 'passed' as const, + line + })), + assertions: [] + }; + } + + it('matches each boundary to the entry whose line falls inside it', () => { + const fileResult = makeFileResult([1, 5, 9]); + const boundaries = [ + { startLine: 1, endLine: 3 }, + { startLine: 5, endLine: 7 }, + { startLine: 9, endLine: 11 } + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes).toHaveLength(3); + expect(outcomes.map(o => o.entries.map(e => e.name))).toEqual([['Entry 1'], ['Entry 2'], ['Entry 3']]); + }); + + it('reports an empty-entries outcome for a boundary with no request, without shifting later boundaries', () => { + // Reproduces the header-cell bug: a comment-only block (no request line, + // so it produces zero report entries) sits between two real requests. + // Index-based zipping would misattribute entry 2 to the comment block's + // slot and leave the real last request unmatched; line-range matching + // must not. + const fileResult = makeFileResult([1, 9]); // no entry inside the middle (comment) boundary + const boundaries = [ + { startLine: 1, endLine: 3 }, // real request - has an entry + { startLine: 5, endLine: 6 }, // comment-only cell - no entry + { startLine: 9, endLine: 11 } // real request - has an entry + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes).toHaveLength(3); + expect(outcomes[0].entries.map(e => e.name)).toEqual(['Entry 1']); + expect(outcomes[1].entries).toEqual([]); + expect(outcomes[2].entries.map(e => e.name)).toEqual(['Entry 2']); + }); + + it('marks trailing boundaries with empty entries when hurl stopped before reaching them', () => { + const fileResult = makeFileResult([1, 5]); + const boundaries = [ + { startLine: 1, endLine: 3 }, + { startLine: 5, endLine: 7 }, + { startLine: 9, endLine: 11 }, + { startLine: 13, endLine: 15 } + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes.slice(0, 2).every(o => o.entries.length === 1)).toBe(true); + expect(outcomes.slice(2).every(o => o.entries.length === 0)).toBe(true); + }); + + it('reports every boundary as empty when the file produced no entries at all', () => { + const fileResult = makeFileResult([]); + const boundaries = [{ startLine: 1, endLine: 3 }, { startLine: 5, endLine: 7 }]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes.every(o => o.entries.length === 0)).toBe(true); + }); + + it('attributes multiple entries to a single boundary when a cell contains more than one request', () => { + const fileResult = makeFileResult([1, 2]); + const boundaries = [{ startLine: 1, endLine: 3 }]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes[0].entries.map(e => e.name)).toEqual(['Entry 1', 'Entry 2']); + }); + + it('places an entry whose line could not be resolved instead of dropping it', () => { + // An entry hurl reported but whose line neither the report nor the + // source scan could pin down. Dropping it would render a request that + // really ran as "not run". + const fileResult = makeFileResult([1]); + fileResult.entries.push({ name: 'Lineless entry', status: 'passed' }); + const boundaries = [ + { startLine: 1, endLine: 3 }, + { startLine: 5, endLine: 7 } + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + expect(outcomes[0].entries.map(e => e.name)).toEqual(['Entry 1']); + expect(outcomes[1].entries.map(e => e.name)).toEqual(['Lineless entry']); + }); + + it('never discards a lineless entry when every boundary is already claimed', () => { + // Both boundaries are taken by line-matched entries, so there is no + // free slot left - the extra entry must still surface somewhere + // rather than vanishing from the notebook entirely. + const fileResult = makeFileResult([1, 5]); + fileResult.entries.push({ name: 'Lineless entry', status: 'passed' }); + const boundaries = [ + { startLine: 1, endLine: 3 }, + { startLine: 5, endLine: 7 } + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + const rendered = outcomes.flatMap(o => o.entries.map(e => e.name)); + expect(rendered).toContain('Lineless entry'); + expect(rendered).toHaveLength(3); + }); + + it('does not let a lineless entry displace a boundary a line-matched entry already claimed', () => { + const fileResult = makeFileResult([5]); // matches the second boundary + fileResult.entries.push({ name: 'Lineless entry', status: 'passed' }); + const boundaries = [ + { startLine: 1, endLine: 3 }, + { startLine: 5, endLine: 7 } + ]; + + const outcomes = mapFileResultToCellOutcomes(fileResult, boundaries); + + // The lineless entry takes the free first boundary; the line-matched + // one keeps the boundary its own line actually falls inside. + expect(outcomes[0].entries.map(e => e.name)).toEqual(['Lineless entry']); + expect(outcomes[1].entries.map(e => e.name)).toEqual(['Entry 1']); + }); +}); diff --git a/workspaces/hurl-client/hurl-client-extension/README.md b/workspaces/hurl-client/hurl-client-extension/README.md index 38c38726286..3229009dc08 100644 --- a/workspaces/hurl-client/hurl-client-extension/README.md +++ b/workspaces/hurl-client/hurl-client-extension/README.md @@ -31,3 +31,51 @@ Content-Type: application/json "name": "Alice" } ``` + +## Chaining requests + +Each request is its own notebook cell. Run a single cell by itself, and it runs on its own — with no access to variables captured by other cells. + +To chain requests, so a variable captured in one is available to the next, run them **together**: select multiple cells (or use **Run All**) instead of running one at a time. + +```hurl +POST https://api.example.com/login +Content-Type: application/json +{ + "username": "alice", + "password": "secret" +} +HTTP 200 +[Captures] +token: jsonpath "$.token" + +GET https://api.example.com/profile +Authorization: Bearer {{token}} +HTTP 200 +``` + +Select both cells above and run them together — `{{token}}` only resolves because the cell that captures it ran in the same invocation. + +## Variables + +To set your own variables (like a base URL or API key), create a file named `hurl.vars`: + +```ini +base_url=https://api.example.com +api_key=your-api-key +``` + +Hurl Client looks for it in the folder set by `hurl-client.fileRoot`, which defaults to the notebook's own folder — so if you haven't changed that setting, put `hurl.vars` next to your `.hurl` files and it is picked up automatically. Reference the values as `{{base_url}}`, `{{api_key}}`, etc. in your requests. + +Need a different value for just one file? Add `.hurl.vars` (e.g. `requests.hurl.vars` for `requests.hurl`) — it overrides the shared file for the values it defines. + +Keep real secrets out of `hurl.vars` if you commit it to version control. + +## Settings + +| Setting | What it does | +|---------|---------------| +| `hurl-client.fileRoot` | Folder used for file references and for the shared `hurl.vars` file. A relative path is resolved against the workspace folder containing the notebook | +| `hurl-client.insecure` | Skip TLS certificate checks | +| `hurl-client.followRedirects` | Follow HTTP redirects | +| `hurl-client.extraArgs` | Pass any other hurl command-line flag not listed above | diff --git a/workspaces/hurl-client/hurl-client-extension/package.json b/workspaces/hurl-client/hurl-client-extension/package.json index 1b55ef2f7bc..45f22233ea0 100644 --- a/workspaces/hurl-client/hurl-client-extension/package.json +++ b/workspaces/hurl-client/hurl-client-extension/package.json @@ -2,7 +2,7 @@ "name": "hurl-client", "displayName": "Hurl Client", "description": "Open and run .hurl files as interactive notebooks", - "version": "0.9.3", + "version": "0.9.5", "publisher": "wso2", "repository": { "type": "git", @@ -21,9 +21,7 @@ "Notebooks" ], "activationEvents": [ - "onNotebook:HurlClient", - "onCommand:HurlClient.importHurlString", - "onCommand:HTTPClient.importHurlString" + "onNotebook:HurlClient" ], "icon": "images/icon.png", "main": "./dist/extension.js", @@ -156,7 +154,28 @@ "type": "string", "default": "", "scope": "resource", - "description": "Default file root directory for Hurl notebook execution. If set, overrides the notebook's directory. Used as the base path for file references in Hurl requests." + "description": "Default file root directory for Hurl notebook execution. If set, overrides the notebook's directory. A relative path is resolved against the workspace folder containing the notebook. Used as the base path for file references in Hurl requests. Also the folder Hurl Client looks in for a shared 'hurl.vars' Variables File." + }, + "hurl-client.insecure": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "Skip TLS certificate verification (passes hurl's -k/--insecure flag). Useful for local servers with self-signed certificates." + }, + "hurl-client.followRedirects": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "Follow HTTP redirects (passes hurl's -L/--location flag)." + }, + "hurl-client.extraArgs": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "scope": "resource", + "description": "Additional command-line arguments passed to hurl verbatim, for options Hurl Client does not expose its own setting for." } } } diff --git a/workspaces/hurl-client/hurl-client-extension/src/notebook/HurlNotebookController.ts b/workspaces/hurl-client/hurl-client-extension/src/notebook/HurlNotebookController.ts index 560b4fdf66e..eafe5c6c057 100644 --- a/workspaces/hurl-client/hurl-client-extension/src/notebook/HurlNotebookController.ts +++ b/workspaces/hurl-client/hurl-client-extension/src/notebook/HurlNotebookController.ts @@ -20,18 +20,47 @@ import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { createHurlRunner, HurlFileResult, HurlEntryResult, HurlAssertionResult } from '@wso2/api-tryit-hurl-runner'; +import { + createHurlRunner, + mapFileResultToCellOutcomes, + HurlFileResult, + HurlEntryResult, + HurlAssertionResult, + HurlRunOptions +} from '@wso2/api-tryit-hurl-runner'; +import { composeHurlDocumentWithBoundaries, parseHurlDocument } from '@wso2/api-tryit-hurl-parser'; import { getHurlBinaryManager } from '../hurl/hurl-binary-manager'; const CONTROLLER_ID = 'HurlClient-controller'; const NOTEBOOK_TYPE = 'HurlClient'; const CONTROLLER_LABEL = 'Hurl Client Runner'; +const SHARED_VARIABLES_FILE_NAME = 'hurl.vars'; +// hurl's raw stderr heading is "Undefined variable", but the per-entry +// errorMessage report-parser extracts is just the detail line beneath it +// ("you must set the variable X") - match both forms. +const UNDEFINED_VARIABLE_PATTERN = /undefined variable|you must set the variable/i; + +interface ResolvedRunOptions { + commandPath: string; + fileRoot: string; + variablesFilePaths: string[]; + insecure: boolean; + followRedirects: boolean; + extraArgs: string[]; +} /** - * Notebook controller that executes individual Hurl request cells. + * Notebook controller that executes Hurl request cells. * - * Each cell's text is written to a temporary `.hurl` file and executed via - * `hurl-runner`. The result is rendered as Markdown in the cell output area. + * Every submitted cell with non-empty content runs together, combined into + * one temp file and executed as a single `hurl` invocation - a Chained Run, + * so a variable captured by an earlier entry is available to later ones, + * matching hurl's own native behavior. When only one cell is submitted + * (running it alone via its own play button), this reduces to that cell + * running by itself - an Isolated Run in effect, since there's nothing else + * to combine with or capture from. + * + * Results are rendered as Markdown in each cell's own output area. */ export class HurlNotebookController { private readonly controller: vscode.NotebookController; @@ -45,7 +74,7 @@ export class HurlNotebookController { ); this.controller.supportedLanguages = ['plaintext', 'hurl']; this.controller.supportsExecutionOrder = true; - this.controller.executeHandler = this.executeHandler.bind(this); + this.controller.executeHandler = this.executeCells.bind(this); // Auto-select this controller as the preferred kernel for every notebook of our type, // so VS Code never shows the "Select Kernel" prompt. @@ -63,77 +92,132 @@ export class HurlNotebookController { this.controller.dispose(); } - private async executeHandler( + private async executeCells( cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument, controller: vscode.NotebookController ): Promise { + const executions: Array<{ execution: vscode.NotebookCellExecution; content: string; startedAt: number }> = []; + for (const cell of cells) { - await this.executeCell(cell, notebook, controller); - } - } + const execution = controller.createNotebookCellExecution(cell); + const startedAt = Date.now(); + execution.start(startedAt); + execution.clearOutput(); - private async executeCell( - cell: vscode.NotebookCell, - notebook: vscode.NotebookDocument, - controller: vscode.NotebookController - ): Promise { - const execution = controller.createNotebookCellExecution(cell); - execution.start(Date.now()); - execution.clearOutput(); + const content = cell.document.getText().trim(); + if (!content) { + execution.end(true, startedAt); + continue; + } - const hurlContent = cell.document.getText().trim(); - if (!hurlContent) { - execution.end(true, Date.now()); + executions.push({ execution, content, startedAt }); + } + + if (executions.length === 0) { return; } let tempDir: string | undefined; + // Tracks how many of `executions` (in order) have already had end() + // called on them, so the catch block below never re-ends a cell the + // try block already finished - VS Code's NotebookCellExecution does + // not support ending or appending to an already-ended execution. + let endedCount = 0; try { - const commandPath = await getHurlBinaryManager().resolveCommandPath({ - promptOnFailure: true - }); + const runOptions = await this.resolveRunOptions(notebook); + // A "cell" here isn't guaranteed to be exactly one request - e.g. a + // notebook's leading comment block becomes its own code cell with + // no request line at all. Boundaries (not array position) are what + // let a zero-entry cell sit anywhere in the chain without shifting + // every entry after it onto the wrong cell. + const { document: combinedContent, boundaries } = composeHurlDocumentWithBoundaries( + executions.map(item => item.content) + ); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http-book-')); - const tempFile = path.join(tempDir, 'cell.hurl'); - await fs.writeFile(tempFile, hurlContent, 'utf-8'); - - // Determine fileRoot: check configuration and fallback to notebook directory. - const configuredFileRoot = vscode.workspace.getConfiguration('hurl-client', notebook.uri).get('fileRoot'); - const notebookPath = notebook.uri.fsPath; - const fileRoot = configuredFileRoot || path.dirname(notebookPath); + const tempFile = path.join(tempDir, 'combined.hurl'); + await fs.writeFile(tempFile, combinedContent, 'utf-8'); const runner = createHurlRunner(); const result = await runner.run( - { collectionPath: tempDir, includePatterns: ['cell.hurl'] }, - { commandPath, includeResponseOutput: true, continueOnError: true, fileRoot: fileRoot } + { collectionPath: tempDir, includePatterns: ['combined.hurl'] }, + { ...this.toHurlRunOptions(runOptions), includeResponseOutput: true, continueOnError: true } ); const fileResult = result.files[0]; if (!fileResult) { + for (const { execution } of executions) { + await execution.appendOutput([ + new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text( + '> No output returned from hurl execution.', + 'text/markdown' + ) + ]) + ]); + execution.end(false, Date.now()); + endedCount++; + } + return; + } + + // boundaries is built from executions in order with no filtering + // (every execution's content is already non-empty), so boundaries + // and outcomes always line up 1:1 with executions by position. + const outcomes = mapFileResultToCellOutcomes( + fileResult, + boundaries.map(b => ({ startLine: b.startLine, endLine: b.endLine })) + ); + + // Both indexes are whole-run properties, so they're resolved once + // here rather than re-derived from scratch for every entry. + const assertionsByEntry = this.buildAssertionIndex(fileResult); + const undefinedVariableEntries = this.findUndefinedVariableEntries(fileResult); + + for (let index = 0; index < executions.length; index++) { + const { execution, content, startedAt } = executions[index]; + const entries = outcomes[index].entries; + + if (entries.length === 0) { + const kind = classifyCellWithoutResult(content); + const laterEntryExists = outcomes.slice(index + 1).some(o => o.entries.length > 0); + await execution.appendOutput([this.buildSkippedOutput(fileResult, kind, laterEntryExists)]); + // Only a comments-only cell is a success - a cell that + // holds a request, or content hurl could not parse, failed. + // Report no elapsed time either way, since nothing ran. + execution.end(kind === 'comments', startedAt); + endedCount = index + 1; + continue; + } + + const outputs = entries.map(entry => this.buildEntryOutput( + entry, + assertionsByEntry.get(entry) || [], + this.resolveResponseBody(entry, fileResult) + )); + if (entries.some(entry => undefinedVariableEntries.has(entry))) { + outputs.push(this.buildUndefinedVariableHintOutput()); + } + await execution.appendOutput(outputs); + execution.end( + entries.every(entry => entry.status === 'passed'), + cellFinishedAt(startedAt, entries) + ); + endedCount = index + 1; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Only the executions the try block never got to end are still + // "running" - re-ending ones it already finished would throw. + for (const { execution } of executions.slice(endedCount)) { await execution.appendOutput([ new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text( - '> No output returned from hurl execution.', - 'text/markdown' - ) + vscode.NotebookCellOutputItem.error({ name: 'HurlNotebookError', message }) ]) ]); execution.end(false, Date.now()); - return; } - - const outputs = this.buildOutputs(fileResult); - await execution.appendOutput(outputs); - execution.end(fileResult.status === 'passed', Date.now()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await execution.appendOutput([ - new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.error({ name: 'HurlNotebookError', message }) - ]) - ]); - execution.end(false, Date.now()); } finally { if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); @@ -141,51 +225,264 @@ export class HurlNotebookController { } } - private buildOutputs(fileResult: HurlFileResult): vscode.NotebookCellOutput[] { - const outputs: vscode.NotebookCellOutput[] = []; + /** + * Resolves commandPath, fileRoot, the Variables File(s), and the CLI + * flag settings once per run from the notebook's configuration scope. + */ + private async resolveRunOptions(notebook: vscode.NotebookDocument): Promise { + const commandPath = await getHurlBinaryManager().resolveCommandPath({ promptOnFailure: true }); + const config = vscode.workspace.getConfiguration('hurl-client', notebook.uri); - if (fileResult.entries.length > 0) { - for (const entry of fileResult.entries) { - const entryAssertions = this.assertionsForEntry(entry, fileResult); - const md = this.formatEntry(entry, entryAssertions, fileResult.entries.length === 1 ? fileResult.stdout : undefined); - outputs.push( - new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(md, 'text/markdown') - ]) - ); + const configuredFileRoot = config.get('fileRoot')?.trim(); + const notebookPath = notebook.uri.fsPath; + const fileRoot = this.resolveFileRoot(notebook, configuredFileRoot); + + const insecure = config.get('insecure') ?? false; + const followRedirects = config.get('followRedirects') ?? false; + const extraArgs = config.get('extraArgs') ?? []; + const variablesFilePaths = await this.resolveVariablesFilePaths(fileRoot, notebookPath); + + return { commandPath, fileRoot, variablesFilePaths, insecure, followRedirects, extraArgs }; + } + + /** + * `hurl-client.fileRoot` is free text, so it can be relative. Resolving + * it here means it lands where the user meant rather than against the + * extension host's process cwd - which is neither the notebook nor the + * workspace, and would silently skip the shared Variables File and + * mis-root file references inside requests. Relative values resolve + * against the workspace folder owning the notebook (the usual VS Code + * convention for a resource-scoped path setting), falling back to the + * notebook's own folder when it sits outside any workspace folder. + */ + private resolveFileRoot(notebook: vscode.NotebookDocument, configuredFileRoot: string | undefined): string { + const notebookDir = path.dirname(notebook.uri.fsPath); + if (!configuredFileRoot) { + return notebookDir; + } + const workspaceRoot = vscode.workspace.getWorkspaceFolder(notebook.uri)?.uri.fsPath ?? notebookDir; + return path.resolve(workspaceRoot, configuredFileRoot); + } + + /** + * A shared Variables File applies to every `.hurl` file under fileRoot; + * an optional per-file Variables File next to the notebook overrides it + * for the values it defines. Both are hurl's own native + * `--variables-file` format, so hurl parses them - we only check + * whether each one exists. + */ + private async resolveVariablesFilePaths(fileRoot: string, notebookPath: string): Promise { + const sharedPath = path.join(fileRoot, SHARED_VARIABLES_FILE_NAME); + const perFilePath = `${notebookPath}.vars`; + + const [sharedExists, perFileExists] = await Promise.all([pathExists(sharedPath), pathExists(perFilePath)]); + + const paths: string[] = []; + if (sharedExists) { + paths.push(sharedPath); + } + if (perFileExists) { + paths.push(perFilePath); + } + return paths; + } + + private toHurlRunOptions(resolved: ResolvedRunOptions): HurlRunOptions { + return { + commandPath: resolved.commandPath, + fileRoot: resolved.fileRoot, + variablesFilePaths: resolved.variablesFilePaths, + insecure: resolved.insecure, + followRedirects: resolved.followRedirects, + extraArgs: resolved.extraArgs + }; + } + + /** + * Which entries failed on an unset variable, resolved once for the whole + * run. hurl normally pins this to the entry itself; when it only reports + * it at file level (no entry attribution), fall back to flagging the + * entries that actually failed, so the hint still reaches the user + * instead of being lost. Passing entries are never flagged, so the hint + * can't attach to a sibling cell that ran fine. + */ + private findUndefinedVariableEntries(fileResult: HurlFileResult): Set { + const flagged = new Set(); + + for (const entry of fileResult.entries) { + if (entry.status !== 'passed' && UNDEFINED_VARIABLE_PATTERN.test(entry.errorMessage || '')) { + flagged.add(entry); } - } else { - const statusIcon = fileResult.status === 'passed' ? '✅' : '❌'; - const detail = fileResult.errorMessage || fileResult.stderr || 'No response data available.'; - const md = `## ${statusIcon} ${fileResult.status.toUpperCase()}\n\n\`\`\`\n${detail}\n\`\`\``; - outputs.push( - new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(md, 'text/markdown') - ]) - ); + } + if (flagged.size > 0) { + return flagged; } - return outputs; + const fileLevelSignal = UNDEFINED_VARIABLE_PATTERN.test(fileResult.stderr || '') + || UNDEFINED_VARIABLE_PATTERN.test(fileResult.errorMessage || ''); + if (!fileLevelSignal) { + return flagged; + } + + for (const entry of fileResult.entries) { + if (entry.status !== 'passed') { + flagged.add(entry); + } + } + return flagged; + } + + private buildUndefinedVariableHintOutput(): vscode.NotebookCellOutput { + const md = '> 💡 This request references a variable that has not been set. If it depends on a value captured by an earlier request, run all cells (or select a range starting from the capturing request) so captured variables are available.'; + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); + } + + /** + * Falls back to hurl's `-i` stdout when the report didn't capture a body. + * + * hurl writes exactly one response to stdout - the run's last entry - + * regardless of how many entries the file has, so the fallback belongs to + * the last entry rather than to a single-entry run (which is just the + * special case where the only entry is also the last). Earlier entries + * are absent from stdout entirely, so there is nothing to fall back to + * for them. When the last entry got no response at all, hurl leaves + * stdout empty rather than emitting an earlier entry's body, so this + * can't attribute someone else's response to it. + */ + private resolveResponseBody(entry: HurlEntryResult, fileResult: HurlFileResult): string | undefined { + if (entry.responseBody !== undefined) { + return entry.responseBody; + } + const isLastEntry = fileResult.entries.length > 0 + && entry === fileResult.entries[fileResult.entries.length - 1]; + return isLastEntry ? extractResponseBody(fileResult.stdout) : undefined; + } + + private buildSkippedOutput( + fileResult: HurlFileResult, + kind: CellWithoutResultKind, + laterEntryExists: boolean + ): vscode.NotebookCellOutput { + if (kind === 'comments') { + const md = '##### ℹ️ NO REQUEST\n\nThis cell has no request to run.'; + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); + } + + if (kind === 'unparsed') { + const detail = fileResult.errorMessage || fileResult.stderr; + const detailBlock = detail ? `\n\n\`\`\`\n${detail}\n\`\`\`` : ''; + const md = `##### ❌ NOT PARSED\n\nNo runnable request was found in this cell. If it is meant to be a request, check its syntax - a malformed request also stops the rest of the run.${detailBlock}`; + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); + } + + if (laterEntryExists) { + // A later cell in the same run did produce an entry, so the run + // didn't stop - hurl reached this request and skipped or failed + // to parse/execute it specifically, which is a different problem + // than the run being cut short. + const md = '##### ⚠️ NOT EXECUTED\n\nHurl could not run this request (it was skipped or failed to parse), but later requests in this run still executed.'; + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); + } + + const detail = fileResult.errorMessage || fileResult.stderr; + const detailBlock = detail ? `\n\n\`\`\`\n${detail}\n\`\`\`` : ''; + const md = `##### ⏭️ NOT RUN\n\nThis request was not executed because the run stopped before reaching it.${detailBlock}`; + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); } - private assertionsForEntry( + private buildEntryOutput( entry: HurlEntryResult, - fileResult: HurlFileResult - ): HurlAssertionResult[] { - const entryAssertions = entry.assertions || []; - if (entryAssertions.length > 0) { - return entryAssertions; + assertions: HurlAssertionResult[], + responseBody: string | undefined + ): vscode.NotebookCellOutput { + const md = this.formatEntry(entry, assertions, responseBody); + return new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(md, 'text/markdown') + ]); + } + + /** + * Groups assertions per entry in one pass over the run, instead of + * re-filtering the whole assertion list for every entry rendered. + * + * report-parser already attaches assertions to entries by name or line + * range, so an entry that has its own list keeps exactly that. The + * fallback below only exists for entries report-parser left empty, and + * only claims assertions it can tie to that specific entry - a stray + * assertion that resolves to nobody is left out rather than parked on + * the first entry, which would put it under the wrong cell in a chain. + */ + private buildAssertionIndex(fileResult: HurlFileResult): Map { + const index = new Map(); + const needsFallback = new Set(); + + for (const entry of fileResult.entries) { + const own = entry.assertions || []; + index.set(entry, own.length > 0 ? own : []); + if (own.length === 0) { + needsFallback.add(entry); + } } - return fileResult.assertions.filter(a => - (entry.name && a.entryName === entry.name) || - (!a.entryName && fileResult.entries.indexOf(entry) === 0) - ); + + if (needsFallback.size === 0) { + return index; + } + + const entriesByName = new Map(); + for (const entry of fileResult.entries) { + if (entry.name && !entriesByName.has(entry.name)) { + entriesByName.set(entry.name, entry); + } + } + + for (const assertion of fileResult.assertions) { + const owner = assertion.entryName + ? entriesByName.get(assertion.entryName) + : this.findEntryForLine(fileResult, assertion.line); + if (owner && needsFallback.has(owner)) { + index.get(owner)!.push(assertion); + } + } + + return index; + } + + /** + * Returns undefined, rather than guessing, when an assertion can't be + * tied to a specific entry - in a chained run showing it under the wrong + * cell would be worse than not showing it at all (before chaining, a run + * always had exactly one entry, so any default was harmless). + */ + private findEntryForLine(fileResult: HurlFileResult, line: number | undefined): HurlEntryResult | undefined { + if (typeof line !== 'number') { + return undefined; + } + let closest: HurlEntryResult | undefined; + for (const candidate of fileResult.entries) { + if (typeof candidate.line !== 'number' || candidate.line > line) { + continue; + } + if (!closest || (closest.line ?? -Infinity) < candidate.line) { + closest = candidate; + } + } + return closest; } private formatEntry( entry: HurlEntryResult, assertions: HurlAssertionResult[], - stdout?: string + responseBody?: string ): string { const lines: string[] = []; @@ -215,7 +512,6 @@ export class HurlNotebookController { } } - const responseBody = extractResponseBody(stdout); if (responseBody) { lines.push(''); const { lang, text } = formatBody(responseBody); @@ -229,6 +525,56 @@ export class HurlNotebookController { } } +async function pathExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +/** + * Why a cell produced no result: + * - `request` it does contain a request, so hurl either never reached it + * or refused to run it + * - `comments` nothing but comments/blank lines - genuinely nothing to run + * - `unparsed` it has real content that the parser found no request in, + * i.e. it is most likely malformed + * + * `comments` and `unparsed` must stay distinct: reporting a malformed request + * as "nothing to run" would end the cell successfully and hide the error. + */ +type CellWithoutResultKind = 'request' | 'comments' | 'unparsed'; + +function classifyCellWithoutResult(content: string): CellWithoutResultKind { + if (parseHurlDocument(content).blocks.length > 0) { + return 'request'; + } + const meaningfulLines = content + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith('#')); + return meaningfulLines.length === 0 ? 'comments' : 'unparsed'; +} + +/** + * The end timestamp to report for a cell, so VS Code's per-cell timer shows + * how long that cell's own request(s) took rather than how long the whole + * batch took. Every cell in a run is started at once and ended once the + * single combined `hurl` process exits, so using wall-clock here would label + * a 30ms request with the full run duration - and disagree with the + * per-request timing already shown in the cell's own output. Falls back to + * wall-clock when hurl reported no timings at all. + */ +function cellFinishedAt(startedAt: number, entries: HurlEntryResult[]): number { + const timed = entries.filter(entry => typeof entry.durationMs === 'number'); + if (timed.length === 0) { + return Date.now(); + } + return startedAt + timed.reduce((total, entry) => total + (entry.durationMs || 0), 0); +} + /** * Extract the response body from hurl's `-i` stdout output. * The output format is: status line + headers + blank line + body.