diff --git a/packages/oc-docs/e2e/components/playground.component.ts b/packages/oc-docs/e2e/components/playground.component.ts index ea549374..2594f989 100644 --- a/packages/oc-docs/e2e/components/playground.component.ts +++ b/packages/oc-docs/e2e/components/playground.component.ts @@ -2,12 +2,18 @@ import type { Locator } from '@playwright/test'; import { BaseComponent } from './base.component'; import { KeyValueTableComponent } from './key-value-table/key-value-table.component'; import { CodeEditorComponent } from './code-editor/code-editor.component'; +import { QueryBarComponent } from './playground/query-bar.component'; +import { VariableCardComponent } from './variable-card/variable-card.component'; +import { EnvSwitcherComponent } from './layout/env-switcher.component'; import type { DockMode } from '../../src/utils/playgroundDock'; export class PlaygroundComponent extends BaseComponent { readonly keyValueTable = new KeyValueTableComponent(this.page); readonly preRequestScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-pre-request'); readonly postResponseScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-post-response'); + readonly queryBar = new QueryBarComponent(this.page); + readonly variableCard = new VariableCardComponent(this.page); + readonly environmentSwitcher = new EnvSwitcherComponent(this.page, 'playground-env-switcher'); readonly header = this.page.getByTestId('playground-header'); readonly switcher = this.page.getByTestId('playground-dock-switcher'); diff --git a/packages/oc-docs/e2e/components/playground/query-bar.component.ts b/packages/oc-docs/e2e/components/playground/query-bar.component.ts new file mode 100644 index 00000000..53ec2db7 --- /dev/null +++ b/packages/oc-docs/e2e/components/playground/query-bar.component.ts @@ -0,0 +1,31 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +/** + * The playground request URL bar. The URL is a HighlightedInput whose real + * carries the test id; the {{variable}} autocomplete list is portaled + * to under its own test id, so both are located page-wide. + * + * The highlight mirror that paints the tokens is aria-hidden and its per-token + * spans carry no test id (they are generated), so token validity is read from + * the .variable-valid / .variable-invalid classes the component assigns - the + * same classes the app hit-tests for hover. A valid token is resolvable (green); + * an invalid one is not (red). + */ +export class QueryBarComponent extends BaseComponent { + readonly url = this.page.getByTestId('query-bar-url'); + readonly copyButton = this.page.getByTestId('query-bar-copy-url'); + readonly autocomplete = this.page.getByTestId('variable-autocomplete'); + + option(name: string): Locator { + return this.autocomplete.getByRole('option', { name }); + } + + validToken(name: string): Locator { + return this.page.locator('.highlight-input-mirror .variable-valid').filter({ hasText: `{{${name}}}` }); + } + + invalidToken(name: string): Locator { + return this.page.locator('.highlight-input-mirror .variable-invalid').filter({ hasText: `{{${name}}}` }); + } +} diff --git a/packages/oc-docs/e2e/tests/playground/playground-variable-resolution.spec.ts b/packages/oc-docs/e2e/tests/playground/playground-variable-resolution.spec.ts new file mode 100644 index 00000000..7f8568c4 --- /dev/null +++ b/packages/oc-docs/e2e/tests/playground/playground-variable-resolution.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../playwright'; + +const OPEN = '/#/?pg=1&dock=bottom'; +const REQUEST = 'get users'; +const EXAMPLE = 'List Users'; + +test.describe('Playground - variable resolution in URLs', () => { + test.beforeEach(async ({ page, playground }) => { + await page.goto(OPEN); + await playground.environmentSwitcher.selectEnvironment('Local'); + }); + + test('example view URL resolves its variable with a hover card', async ({ playground }) => { + await playground.exampleToggle(REQUEST).click(); + await playground.exampleRow(EXAMPLE).click(); + await expect(playground.exampleView).toBeVisible(); + + const { variableCard } = playground; + await variableCard.hoverToken('host'); + + await expect(variableCard.card).toBeVisible(); + await expect(variableCard.name).toHaveText('host'); + await expect(variableCard.scopeBadge).toHaveText('Environment'); + await expect(variableCard.value).toHaveText('http://localhost:8081'); + }); + + test('request URL bar shows the variable and offers variable autocomplete', async ({ playground }) => { + await playground.openSidebarItem(REQUEST); + + const { queryBar } = playground; + await expect(queryBar.url).toBeVisible(); + await expect(queryBar.url).toHaveValue(/\{\{host\}\}/); + + await queryBar.url.fill(''); + await queryBar.url.pressSequentially('{{h'); + + await expect(queryBar.autocomplete).toBeVisible(); + await expect(queryBar.option('host')).toBeVisible(); + }); +}); + +// Regression for a QA-reported bug: request-scoped and folder-scoped variables +// referenced in the playground were painted red (treated as undefined) even +// though they are defined, because the resolver in scope must include the open +// request and its ancestor folders, not just collection + environment. +test.describe('Playground - request/folder scoped variables resolve', () => { + test.beforeEach(async ({ page, playground }) => { + await page.goto('/?fixture=vars#/?pg=1&dock=bottom'); + await playground.environmentSwitcher.selectEnvironment('Dev'); + await playground.openTreeItem(['Customers', 'Variables Demo']); + await expect(playground.queryBar.url).toBeVisible(); + }); + + test('a request-scoped variable in the URL is valid, not undefined', async ({ playground }) => { + // userId is defined in the request runtime variables (req-42). + await expect(playground.queryBar.validToken('userId')).toHaveCount(1); + await expect(playground.queryBar.invalidToken('userId')).toHaveCount(0); + }); + + test('a folder-scoped variable in a header is valid, not undefined', async ({ playground }) => { + // folderScope is defined on the parent Customers folder (from-folder). + await playground.selectTab('headers'); + await expect(playground.queryBar.validToken('folderScope')).toHaveCount(1); + await expect(playground.queryBar.invalidToken('folderScope')).toHaveCount(0); + }); +}); diff --git a/packages/oc-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/oc-docs/src/components/HighlightedInput/HighlightedInput.tsx index 08282f13..76b363ca 100644 --- a/packages/oc-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/oc-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -25,6 +25,7 @@ interface HighlightedInputProps { title?: string; testId?: string; multiline?: boolean; + onEnter?: () => void; } interface HoveredToken { @@ -75,7 +76,8 @@ export const HighlightedInput: React.FC = ({ variablesAutocomplete = true, title, testId, - multiline = false + multiline = false, + onEnter }) => { const inputRef = useRef(null); const mirrorRef = useRef(null); @@ -253,7 +255,13 @@ export const HighlightedInput: React.FC = ({ }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (!autocomplete) return; + if (!autocomplete) { + if (event.key === 'Enter' && !multiline && onEnter && !event.nativeEvent.isComposing) { + event.preventDefault(); + onEnter(); + } + return; + } const { items, active } = autocomplete; switch (event.key) { case 'ArrowDown': diff --git a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx index b30cf220..c61ec7fa 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx @@ -6,6 +6,7 @@ import { PropertyTable } from '../../../../PropertyTable/PropertyTable'; import { RequestParams } from '../../../../Request/RequestParams/RequestParams'; import { RequestBody } from '../../../../Request/RequestBody/RequestBody'; import { Code } from '../../../../Code/Code'; +import { VariableText } from '../../../../VariableText/VariableText'; import { SplitDivider } from '../../../../SplitDivider/SplitDivider'; import { resolvePathAndQueryParams } from '../../../../../utils/pathParams'; import { getBodyView, getDescription, headerRows } from '../../../../../utils/request'; @@ -74,7 +75,7 @@ export const ExampleView: React.FC = ({ request, example, orie
- {url} + {statusBadge}
diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx index 3226b248..b9269e4a 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx @@ -7,6 +7,8 @@ import { syncPathParams, syncQueryParams } from '../../../../../../utils/pathPar import { availableMethods, getMethodColorVar } from '../../../../../../theme/methodColors'; import { MethodBadge } from '../../../../../MethodBadge/MethodBadge'; import { CopyButton } from '../../../../../../ui/CopyButton/CopyButton'; +import { HighlightedInput } from '../../../../../HighlightedInput/HighlightedInput'; +import { useResolvedVariables } from '../../../../../../hooks'; import { SendIcon } from '../../../../../../assets/icons'; interface QueryBarProps { @@ -17,6 +19,7 @@ interface QueryBarProps { } const QueryBar: React.FC = ({ item, onSendRequest, isLoading, onItemChange }) => { + const { isFound, names } = useResolvedVariables(); const [url, setUrl] = useState(getRequestUrl(item)); const [method, setMethod] = useState(getHttpMethod(item)); @@ -75,13 +78,15 @@ const QueryBar: React.FC = ({ item, onSendRequest, isLoading, onI - handleUrlChange(e.target.value)} + onValueChange={handleUrlChange} placeholder="Enter request URL" - onKeyPress={(e) => { - if (e.key === 'Enter' && url.trim() && !isLoading) { + isFound={isFound} + names={names} + testId="query-bar-url" + onEnter={() => { + if (url.trim() && !isLoading) { onSendRequest(); } }} diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts index e3fa8bc5..ae92fea7 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts @@ -9,23 +9,29 @@ export const StyledWrapper = styled.div` border-radius: var(--oc-radius); background-color: var(--bg-primary); - input { + && .highlight-input { flex: 1; min-width: 0; - outline: none; - border: none; - border-radius: 0; - background-color: transparent; + padding: 0; font-family: var(--font-mono); - font-weight: 400; + } + + && .highlight-input .text-input, + && .highlight-input .highlight-input-mirror { + padding: 0; font-size: 0.75rem; line-height: 1.125rem; + } + + && .highlight-input .highlight-input-mirror { + right: 0; + left: 0; color: var(--text-primary); + } - &::placeholder { - color: var(--text-secondary); - opacity: 0.6; - } + && .highlight-input .text-input::placeholder { + color: var(--text-secondary); + opacity: 0.6; } .method-select-wrapper {