Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Locator, Page, Route } from '@playwright/test';
import { test, expect } from '../../playwright';

const VARS_PLAYGROUND = '/?fixture=vars#/?pg=1&dock=bottom';

test.describe('Playground prompt variables', () => {
test.beforeEach(async ({ page, playground }) => {
await page.goto(VARS_PLAYGROUND);
await playground.runner.waitFor({ state: 'visible' });
await playground.openTreeItem(['Customers', 'Variables Demo']);
await playground.view.waitFor({ state: 'visible' });
await playground.envSwitcher.selectEnvironment('Dev');
});

const armRequest = async (page: Page, urlInput: Locator, url: string) => {
const sent: string[] = [];
await page.route('**/*', async (route: Route) => {
const target = route.request().url();
if (target.startsWith('http') && !target.includes('127.0.0.1') && !target.includes('localhost')) {
sent.push(target);
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
return;
}
await route.continue();
});

await urlInput.fill(url);
await expect(urlInput).toHaveValue(url);
return sent;
};

test('asks for a value before sending and interpolates what the reader typed', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();

const dialog = page.getByTestId('prompt-variables-modal-content');
await expect(dialog).toBeVisible();
await expect(page.getByText('Input Required')).toBeVisible();

await page.getByTestId('prompt-variable-input-0').fill('123456');
await page.getByTestId('prompt-variables-submit').click();

await expect(dialog).toHaveCount(0);
await expect.poll(() => sent).toContain('https://api.example.com/otp/123456');
});

test('submits on Enter, so the reader never has to reach for the mouse', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();
await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible();

await page.keyboard.type('654321');
await page.keyboard.press('Enter');

await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0);
await expect.poll(() => sent).toContain('https://api.example.com/otp/654321');
});

test('asks again on the next send, because answers are never kept', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();
await page.getByTestId('prompt-variable-input-0').fill('111111');
await page.getByTestId('prompt-variables-submit').click();
await expect.poll(() => sent.length).toBe(1);

await responsePane.send();

const dialog = page.getByTestId('prompt-variables-modal-content');
await expect(dialog).toBeVisible();
await expect(page.getByTestId('prompt-variable-input-0')).toHaveValue('');

await page.getByTestId('prompt-variable-input-0').fill('222222');
await page.getByTestId('prompt-variables-submit').click();
await expect.poll(() => sent).toContain('https://api.example.com/otp/222222');
});

test('sends nothing when the reader backs out', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();
await page.getByTestId('prompt-variables-cancel').click();

await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0);
await expect(responsePane.emptyHint).toBeVisible();
expect(sent).toEqual([]);
});

test('closes on Escape without sending', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();
await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible();
await page.keyboard.press('Escape');

await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0);
await expect(responsePane.emptyHint).toBeVisible();
expect(sent).toEqual([]);
});

test('puts the cursor in the first field so the reader can type straight away', async ({ page, playground, responsePane }) => {
await armRequest(page, playground.urlInput, 'https://api.example.com/{{?First}}/{{?Second}}');

await responsePane.send();
await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible();

await page.keyboard.type('typed-without-clicking');
await expect(page.getByTestId('prompt-variable-input-0')).toHaveValue('typed-without-clicking');
});

test('asks for one field per prompt and sends both answers', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/{{?First}}/{{?Second}}');

await responsePane.send();

await expect(page.getByTestId('prompt-variable-input-container')).toHaveCount(2);
await page.getByTestId('prompt-variable-input-0').fill('one');
await page.getByTestId('prompt-variable-input-1').fill('two');
await page.getByTestId('prompt-variables-submit').click();

await expect.poll(() => sent).toContain('https://api.example.com/one/two');
});

test('sends an empty string for a field left blank, rather than the token', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}');

await responsePane.send();
await page.getByTestId('prompt-variables-submit').click();

await expect.poll(() => sent).toContain('https://api.example.com/otp/');
});

test('sends without asking when the request uses no prompt variables', async ({ page, playground, responsePane }) => {
const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/plain');

await responsePane.send();

await expect.poll(() => sent).toContain('https://api.example.com/plain');
await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import QueryBar from './QueryBar/QueryBar';
import RequestPane from './RequestPane/RequestPane';
import ResponsePane from './ResponsePane/ResponsePane';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { usePromptVariables } from './usePromptVariables';
import { PromptVariablesModal } from '@/components/PromptVariablesModal/PromptVariablesModal';
import {
updatePlaygroundItem,
setPlaygroundResponse,
Expand Down Expand Up @@ -37,6 +39,8 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
const itemUuid = (item as any).uuid;
const response = useAppSelector((state) => selectPlaygroundResponse(state, itemUuid));
const [isLoading, setIsLoading] = useState(false);
const promptVariablesController = usePromptVariables();
const { collect: collectPromptVariables } = promptVariablesController;
// The request/response split is one draggable divider whose axis follows the
// orientation: horizontal layout resizes width, vertical layout resizes height.
const { size: paneSize, isResizing, containerRef, startResize } = useSplitPane(orientation);
Expand Down Expand Up @@ -71,6 +75,7 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec

const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pendingSaveRef = useRef<{ uuid: string; item: HttpRequest } | null>(null);
const sendInFlightRef = useRef(false);

useEffect(() => {
setEditableItem(item);
Expand Down Expand Up @@ -104,7 +109,9 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
}, [dispatch]);

const handleSendRequest = useCallback(async () => {
setIsLoading(true);
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;

try {
// Check both root level and config level for environments
// TODO: Remove this
Expand All @@ -113,11 +120,22 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
(env: any) => env.name === selectedEnvironment
);
const { requestRunner } = await import('@/runner');

const prepared = await requestRunner.prepareRequest(editableItem, collection);
const promptNames = await requestRunner.collectPromptVariableNames({
item: editableItem, collection, environment, prepared
});
const promptVariables = await collectPromptVariables(promptNames);
if (!promptVariables) return;

setIsLoading(true);
const result = await requestRunner.runRequest({
item: editableItem,
collection,
environment,
runtimeVariables: {}
runtimeVariables: {},
promptVariables,
prepared
});

dispatch(setPlaygroundResponse({ uuid: itemUuid, response: result }));
Expand All @@ -136,9 +154,10 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
}
}));
} finally {
sendInFlightRef.current = false;
setIsLoading(false);
}
}, [collection, editableItem, selectedEnvironment, itemUuid, dispatch]);
}, [collection, editableItem, selectedEnvironment, itemUuid, dispatch, collectPromptVariables]);

return (
<ItemVariableResolverProvider
Expand All @@ -148,6 +167,16 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
writable
>
<div className="request-runner-container h-full flex flex-col px-5" style={{ backgroundColor: 'var(--bg-primary)' }}>
{promptVariablesController.pending && (
<PromptVariablesModal
key={promptVariablesController.pending.names.join('\u0000')}
open
names={promptVariablesController.pending.names}
onSubmit={promptVariablesController.submit}
onCancel={promptVariablesController.cancel}
/>
)}

<TitleLabel className="truncate mb-2 mt-5">{itemName}</TitleLabel>

<QueryBar
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { useRenderToDom } from '@/hooks/useRenderToDom';
import { usePromptVariables, type PromptVariablesController } from './usePromptVariables';

const probeTree = () => {
const box: { current: PromptVariablesController | null } = { current: null };

const Probe: React.FC = () => {
box.current = usePromptVariables();
return null;
};

return { box, tree: <Probe /> };
};

describe('asking the reader for prompt values', () => {
it('sends straight away when the request uses no prompts', async () => {
const { box, tree } = probeTree();
useRenderToDom(tree);
const controller = box.current as PromptVariablesController;

await expect(controller.collect([])).resolves.toEqual({});
});

it('waits for the reader, then sends what they typed', async () => {
const { box, tree } = probeTree();
useRenderToDom(tree);
const controller = box.current as PromptVariablesController;

const pending = controller.collect(['OTP', 'Region']);
let settled = false;
void pending.then(() => {
settled = true;
});

await Promise.resolve();
expect(settled).toBe(false);

controller.submit({ OTP: '123456', Region: 'eu' });

await expect(pending).resolves.toEqual({ '?OTP': '123456', '?Region': 'eu' });
});

it('asks again on the next send, because answers are never kept', async () => {
const { box, tree } = probeTree();
useRenderToDom(tree);
const controller = box.current as PromptVariablesController;

const first = controller.collect(['OTP']);
controller.submit({ OTP: '123456' });
await expect(first).resolves.toEqual({ '?OTP': '123456' });

const second = controller.collect(['OTP']);
let settled = false;
void second.then(() => {
settled = true;
});

await Promise.resolve();
expect(settled).toBe(false);

controller.cancel();
await expect(second).resolves.toBeNull();
});

it('reports a dismissal so the send is abandoned rather than sent half-filled', async () => {
const { box, tree } = probeTree();
useRenderToDom(tree);
const controller = box.current as PromptVariablesController;

const pending = controller.collect(['OTP']);
controller.cancel();

await expect(pending).resolves.toBeNull();
});

it('sends an empty string for a prompt the reader left blank', async () => {
const { box, tree } = probeTree();
useRenderToDom(tree);
const controller = box.current as PromptVariablesController;

const pending = controller.collect(['OTP']);
controller.submit({});

await expect(pending).resolves.toEqual({ '?OTP': '' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildPromptVariableMap } from '@/utils/promptVariables';
import type { Variables } from '@/runner/utils/variable-interpolator';

interface PendingPrompt {
names: string[];
}

export interface PromptVariablesController {
pending: PendingPrompt | null;
collect: (names: string[]) => Promise<Variables | null>;
submit: (values: Record<string, string>) => void;
cancel: () => void;
}

export const usePromptVariables = (): PromptVariablesController => {
const [pending, setPending] = useState<PendingPrompt | null>(null);
const resolveRef = useRef<((values: Record<string, string> | null) => void) | null>(null);

const settle = useCallback((values: Record<string, string> | null) => {
const resolve = resolveRef.current;
resolveRef.current = null;
setPending(null);
resolve?.(values);
}, []);

const collect = useCallback(
async (names: string[]): Promise<Variables | null> => {
if (!names.length) return {};

settle(null);

const values = await new Promise<Record<string, string> | null>((resolve) => {
resolveRef.current = resolve;
setPending({ names });
});
Comment thread
sachin-bruno marked this conversation as resolved.

return values ? buildPromptVariableMap(names, values) : null;
},
[settle]
);

const submit = useCallback((values: Record<string, string>) => settle(values), [settle]);
const cancel = useCallback(() => settle(null), [settle]);

useEffect(() => () => resolveRef.current?.(null), []);

return useMemo(() => ({ pending, collect, submit, cancel }), [pending, collect, submit, cancel]);
};
Loading
Loading