diff --git a/packages/oc-docs/e2e/README.md b/packages/oc-docs/e2e/README.md index 54aac0d..d08d069 100644 --- a/packages/oc-docs/e2e/README.md +++ b/packages/oc-docs/e2e/README.md @@ -18,6 +18,10 @@ Two rules shape every test here: |------|------| | Collection Overview — version/name, stat counts, environments, configuration | `tests/overview/overview.spec.ts` | | Overview documentation — rendered Markdown | `tests/overview/overview-documentation.spec.ts` | +| Request page — details (breadcrumb, title, method/URL, description, params, auth, code snippet) | `tests/request/request-details.spec.ts` | +| Request page — Examples | `tests/request/request-examples.spec.ts` | +| Request page — Execution Context (scripts, variables, asserts, tests) | `tests/request/request-execution-context.spec.ts` | +| Script page — title, breadcrumb, source code | `tests/script/script.spec.ts` | | Light/dark theme switch | `tests/theming/theme-toggle.spec.ts` | ## Running the tests @@ -36,20 +40,35 @@ e2e/ ├── tests/ # the tests, grouped by feature │ ├── overview/overview.spec.ts │ ├── overview/overview-documentation.spec.ts +│ ├── request/request-details.spec.ts +│ ├── request/request-examples.spec.ts +│ ├── request/request-execution-context.spec.ts +│ ├── script/script.spec.ts │ └── theming/theme-toggle.spec.ts ├── pages/ # one "page object" per screen │ ├── base.page.ts # shared navigation (goto, reload) -│ └── overview.page.ts # OverviewPage — composes its overview/ sections +│ ├── overview.page.ts # OverviewPage — composes its overview/ sections +│ ├── request.page.ts # RequestPage — composes its request/ sections +│ └── script.page.ts # ScriptPage — breadcrumb, title, source code ├── components/ # reusable pieces │ ├── base.component.ts # shared base — every component has a `root` │ ├── markdown.component.ts # any block of rendered Markdown │ ├── secret-value.component.ts # a masked value with a reveal toggle │ ├── theme-toggle.component.ts # the light/dark switch -│ └── overview/ # sections specific to the Overview page -│ ├── header-section.component.ts -│ ├── stats-section.component.ts -│ ├── environments-section.component.ts -│ └── configuration-section.component.ts +│ ├── sidebar.component.ts # the navigation tree (open a request/script) +│ ├── breadcrumb.component.ts # the "folder › folder › page" trail +│ ├── overview/ # sections specific to the Overview page +│ │ ├── header-section.component.ts +│ │ ├── stats-section.component.ts +│ │ ├── environments-section.component.ts +│ │ └── configuration-section.component.ts +│ ├── request/ # sections specific to the Request page +│ │ ├── url-bar.component.ts +│ │ ├── code-snippet.component.ts +│ │ ├── examples.component.ts +│ │ └── execution-context.component.ts +│ └── script/ # sections specific to the Script page +│ └── script-content.component.ts ├── playwright/ # the test harness │ ├── pages.fixture.ts # defines the fixtures │ └── index.ts # merges them; the single import for specs @@ -57,6 +76,11 @@ e2e/ └── tsconfig.json # TypeScript settings for this folder ``` +The Request and Script pages have no URL of their own, so their page objects reach +them by navigating the **sidebar** — `requestPage.open(['billing', 'customers', 'Get +All Customers'])` clicks down the folder trail and waits for the page to render. The +reusable `sidebar` and `breadcrumb` components are shared by both pages. + ## The three building blocks **Page objects** (`pages/`) describe a whole screen. They own navigation (`goto`) and diff --git a/packages/oc-docs/e2e/components/base.component.ts b/packages/oc-docs/e2e/components/base.component.ts index 2a69dda..ccc51a5 100644 --- a/packages/oc-docs/e2e/components/base.component.ts +++ b/packages/oc-docs/e2e/components/base.component.ts @@ -1,10 +1,6 @@ import type { Page, Locator } from '@playwright/test'; export abstract class BaseComponent { - /** - * The element this component is scoped to. Section components receive their - * container; page-wide controls omit it and default to the whole page. - */ readonly root: Locator; constructor(protected readonly page: Page, root?: Locator) { diff --git a/packages/oc-docs/e2e/components/breadcrumb.component.ts b/packages/oc-docs/e2e/components/breadcrumb.component.ts new file mode 100644 index 0000000..52f1615 --- /dev/null +++ b/packages/oc-docs/e2e/components/breadcrumb.component.ts @@ -0,0 +1,17 @@ +import type { Locator, Page } from '@playwright/test'; +import { BaseComponent } from './base.component'; + +export class BreadcrumbComponent extends BaseComponent { + readonly current: Locator; + readonly segments: Locator; + + constructor(page: Page, testId: string) { + super(page, page.getByTestId(testId)); + this.current = page.getByTestId(`${testId}-current`); + this.segments = page.getByTestId(`${testId}-segment`); + } + + segment(name: string): Locator { + return this.segments.filter({ hasText: name }); + } +} diff --git a/packages/oc-docs/e2e/components/overview/collection-configuration.component..ts b/packages/oc-docs/e2e/components/overview/collection-configuration.component.ts similarity index 70% rename from packages/oc-docs/e2e/components/overview/collection-configuration.component..ts rename to packages/oc-docs/e2e/components/overview/collection-configuration.component.ts index 0081c3f..c9b33ec 100644 --- a/packages/oc-docs/e2e/components/overview/collection-configuration.component..ts +++ b/packages/oc-docs/e2e/components/overview/collection-configuration.component.ts @@ -5,22 +5,14 @@ import { SecretValueComponent } from '../secret-value.component'; export class ConfigurationSection extends BaseComponent { readonly root = this.page.getByTestId('collection-config'); - readonly copyButton = this.root.getByTestId('collection-config-copy').first(); + readonly copyButton = this.root.getByTestId('collection-config-tests-copy'); - readonly secret = new SecretValueComponent(this.page, 'collection-config-secret'); + readonly secret = new SecretValueComponent(this.page, 'collection-config-auth-secret'); subHeading(name: string): Locator { return this.root.getByTestId('collection-config-subheading').filter({ hasText: name }); } - row(key: string): Locator { - return this.root.getByTestId('collection-config-row').filter({ hasText: key }); - } - - rowValue(key: string): Locator { - return this.row(key).getByTestId('collection-config-row-value'); - } - async copyToClipboard(): Promise { await this.copyButton.click(); } diff --git a/packages/oc-docs/e2e/components/request/code-snippet.component.ts b/packages/oc-docs/e2e/components/request/code-snippet.component.ts new file mode 100644 index 0000000..ec0e39b --- /dev/null +++ b/packages/oc-docs/e2e/components/request/code-snippet.component.ts @@ -0,0 +1,16 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +export class CodeSnippetComponent extends BaseComponent { + readonly root = this.page.getByTestId('request-code-snippet'); + + readonly code = this.root.locator('code'); + + languageTab(language: string): Locator { + return this.root.getByTestId(`code-snippet-tab-${language}`); + } + + async selectLanguage(language: string): Promise { + await this.languageTab(language).click(); + } +} diff --git a/packages/oc-docs/e2e/components/request/examples.component.ts b/packages/oc-docs/e2e/components/request/examples.component.ts new file mode 100644 index 0000000..f779ddf --- /dev/null +++ b/packages/oc-docs/e2e/components/request/examples.component.ts @@ -0,0 +1,36 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +export class ExamplesComponent extends BaseComponent { + readonly root = this.page.getByTestId('request-examples'); + + readonly items = this.root.getByTestId('example-card'); + + example(name: string): Locator { + return this.items.filter({ hasText: name }); + } + + statusCode(name: string): Locator { + return this.example(name).getByTestId('example-status'); + } + + requestBody(name: string): Locator { + return this.example(name).getByTestId('example-request-pane-body'); + } + + responseBody(name: string): Locator { + return this.example(name).getByTestId('example-response-pane-body'); + } + + async open(name: string): Promise { + await this.example(name).getByTestId('example-toggle').click(); + } + + async selectRequestTab(name: string, tab: string): Promise { + await this.example(name).getByTestId(`example-request-pane-tab-${tab}`).click(); + } + + async selectResponseTab(name: string, tab: string): Promise { + await this.example(name).getByTestId(`example-response-pane-tab-${tab}`).click(); + } +} diff --git a/packages/oc-docs/e2e/components/request/execution-context.component.ts b/packages/oc-docs/e2e/components/request/execution-context.component.ts new file mode 100644 index 0000000..c163b3d --- /dev/null +++ b/packages/oc-docs/e2e/components/request/execution-context.component.ts @@ -0,0 +1,27 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +export class ExecutionContextComponent extends BaseComponent { + readonly root = this.page.getByTestId('execution-context'); + + readonly scripts = this.root.getByTestId('execution-context-scripts'); + readonly variables = this.root.getByTestId('execution-context-variables'); + readonly asserts = this.root.getByTestId('execution-context-asserts'); + readonly tests = this.root.getByTestId('execution-context-tests'); + + script(label: string): Locator { + return this.scripts.getByText(label); + } + + variable(name: string): Locator { + return this.variables.getByText(name, { exact: true }); + } + + assertion(text: string): Locator { + return this.asserts.getByText(text); + } + + testCase(name: string): Locator { + return this.tests.getByText(name); + } +} diff --git a/packages/oc-docs/e2e/components/request/url-bar.component.ts b/packages/oc-docs/e2e/components/request/url-bar.component.ts new file mode 100644 index 0000000..1afba08 --- /dev/null +++ b/packages/oc-docs/e2e/components/request/url-bar.component.ts @@ -0,0 +1,9 @@ +import { BaseComponent } from '../base.component'; + +export class RequestUrlBarComponent extends BaseComponent { + readonly root = this.page.getByTestId('request-url-bar'); + + readonly method = this.root.getByTestId('request-method'); + readonly url = this.root.getByTestId('request-url'); + readonly tryButton = this.root.getByTestId('request-try-button'); +} diff --git a/packages/oc-docs/e2e/components/script/script-content.component.ts b/packages/oc-docs/e2e/components/script/script-content.component.ts new file mode 100644 index 0000000..cc271bd --- /dev/null +++ b/packages/oc-docs/e2e/components/script/script-content.component.ts @@ -0,0 +1,9 @@ +import { BaseComponent } from '../base.component'; + +export class ScriptContentComponent extends BaseComponent { + readonly root = this.page.getByTestId('script-code'); + + readonly code = this.root.locator('code'); + + readonly copyButton = this.root.getByTestId('script-code-copy'); +} diff --git a/packages/oc-docs/e2e/components/sidebar.component.ts b/packages/oc-docs/e2e/components/sidebar.component.ts new file mode 100644 index 0000000..4c3daeb --- /dev/null +++ b/packages/oc-docs/e2e/components/sidebar.component.ts @@ -0,0 +1,16 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from './base.component'; + +export class SidebarComponent extends BaseComponent { + readonly items = this.page.getByTestId('sidebar-item'); + + item(name: string): Locator { + return this.items.filter({ hasText: name }); + } + + async open(trail: string[]): Promise { + for (const name of trail) { + await this.item(name).first().click(); + } + } +} diff --git a/packages/oc-docs/e2e/pages/overview.page.ts b/packages/oc-docs/e2e/pages/overview.page.ts index 5ee8001..b5c0836 100644 --- a/packages/oc-docs/e2e/pages/overview.page.ts +++ b/packages/oc-docs/e2e/pages/overview.page.ts @@ -4,7 +4,7 @@ import { MarkdownComponent } from '../components/markdown.component'; import { HeaderSection } from '../components/overview/header.component'; import { StatsSection } from '../components/overview/collection-stats.component'; import { EnvironmentsSection } from '../components/overview/environments.component'; -import { ConfigurationSection } from '../components/overview/collection-configuration.component.'; +import { ConfigurationSection } from '../components/overview/collection-configuration.component'; export class OverviewPage extends BasePage { readonly root = this.page.getByTestId('overview'); diff --git a/packages/oc-docs/e2e/pages/request.page.ts b/packages/oc-docs/e2e/pages/request.page.ts new file mode 100644 index 0000000..59b2641 --- /dev/null +++ b/packages/oc-docs/e2e/pages/request.page.ts @@ -0,0 +1,32 @@ +import type { Locator } from '@playwright/test'; +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; +import { RequestUrlBarComponent } from '../components/request/url-bar.component'; +import { CodeSnippetComponent } from '../components/request/code-snippet.component'; +import { ExamplesComponent } from '../components/request/examples.component'; +import { ExecutionContextComponent } from '../components/request/execution-context.component'; + +export class RequestPage extends BasePage { + readonly root = this.page.getByTestId('request-page'); + + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'request-breadcrumb'); + readonly title = this.page.getByTestId('request-title'); + readonly urlBar = new RequestUrlBarComponent(this.page); + readonly description = this.page.getByTestId('request-description'); + readonly codeSnippet = new CodeSnippetComponent(this.page); + readonly examples = new ExamplesComponent(this.page); + readonly executionContext = new ExecutionContextComponent(this.page); + + async open(trail: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(trail); + await this.root.waitFor({ state: 'visible' }); + } + + section(label: string): Locator { + const slug = label.toLowerCase().replace(/\s+/g, '-'); + return this.page.getByTestId(`request-section-${slug}`); + } +} diff --git a/packages/oc-docs/e2e/pages/script.page.ts b/packages/oc-docs/e2e/pages/script.page.ts new file mode 100644 index 0000000..1931847 --- /dev/null +++ b/packages/oc-docs/e2e/pages/script.page.ts @@ -0,0 +1,19 @@ +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; +import { ScriptContentComponent } from '../components/script/script-content.component'; + +export class ScriptPage extends BasePage { + readonly root = this.page.getByTestId('script-page'); + + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'script-breadcrumb'); + readonly title = this.page.getByTestId('script-title'); + readonly content = new ScriptContentComponent(this.page); + + async open(trail: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(trail); + await this.root.waitFor({ state: 'visible' }); + } +} diff --git a/packages/oc-docs/e2e/pages/unsupported-request.page.ts b/packages/oc-docs/e2e/pages/unsupported-request.page.ts new file mode 100644 index 0000000..c31ac64 --- /dev/null +++ b/packages/oc-docs/e2e/pages/unsupported-request.page.ts @@ -0,0 +1,18 @@ +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; + +export class UnsupportedRequestPage extends BasePage { + readonly root = this.page.getByTestId('unsupported-request'); + + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'unsupported-request-breadcrumb'); + readonly title = this.page.getByTestId('unsupported-request-title'); + readonly message = this.page.getByTestId('unsupported-request-empty'); + + async open(trail: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(trail); + await this.root.waitFor({ state: 'visible' }); + } +} diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index c73dd71..a69f17d 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -1,15 +1,18 @@ import { test as base } from '@playwright/test'; import { OverviewPage } from '../pages/overview.page'; -import { PageHeaderComponent } from '../components/layout/page-header.component'; +import { RequestPage } from '../pages/request.page'; +import { ScriptPage } from '../pages/script.page'; +import { UnsupportedRequestPage } from '../pages/unsupported-request.page'; +import { SidebarComponent } from '../components/sidebar.component'; import { ThemeToggleComponent } from '../components/theme-toggle.component'; +import { PageHeaderComponent } from '../components/layout/page-header.component'; -/** - * Registers the page objects and shared components as Playwright fixtures, so a - * spec receives a ready instance by destructuring (e.g. `{ pageHeader }`) and - * calls `pageHeader.brandName` directly instead of constructing it. - */ type Fixtures = { overviewPage: OverviewPage; + requestPage: RequestPage; + scriptPage: ScriptPage; + unsupportedRequestPage: UnsupportedRequestPage; + sidebar: SidebarComponent; pageHeader: PageHeaderComponent; themeToggle: ThemeToggleComponent; }; @@ -18,6 +21,18 @@ export const test = base.extend({ overviewPage: async ({ page }, use) => { await use(new OverviewPage(page)); }, + requestPage: async ({ page }, use) => { + await use(new RequestPage(page)); + }, + scriptPage: async ({ page }, use) => { + await use(new ScriptPage(page)); + }, + unsupportedRequestPage: async ({ page }, use) => { + await use(new UnsupportedRequestPage(page)); + }, + sidebar: async ({ page }, use) => { + await use(new SidebarComponent(page)); + }, pageHeader: async ({ page }, use) => { await use(new PageHeaderComponent(page)); }, diff --git a/packages/oc-docs/e2e/tests/overview/overview-documentation.spec.ts b/packages/oc-docs/e2e/tests/overview/overview-documentation.spec.ts index a90ee99..c60d13a 100644 --- a/packages/oc-docs/e2e/tests/overview/overview-documentation.spec.ts +++ b/packages/oc-docs/e2e/tests/overview/overview-documentation.spec.ts @@ -1,7 +1,5 @@ import { test, expect } from '../../playwright'; -// The Markdown rendered inside the Overview's "Overview" (documentation) section. - test.describe('Overview documentation (rendered Markdown)', () => { test.beforeEach(async ({ overviewPage }) => { await overviewPage.goto(); diff --git a/packages/oc-docs/e2e/tests/overview/overview.spec.ts b/packages/oc-docs/e2e/tests/overview/overview.spec.ts index c08d15f..97c02ba 100644 --- a/packages/oc-docs/e2e/tests/overview/overview.spec.ts +++ b/packages/oc-docs/e2e/tests/overview/overview.spec.ts @@ -15,10 +15,10 @@ test.describe('Collection Overview', () => { }); }); - test('shows three stat cards with the request (10), folder (0) and environment (2) counts', async ({ overviewPage }) => { + test('shows three stat cards with the request (41), folder (7) and environment (2) counts', async ({ overviewPage }) => { await expect(overviewPage.stats.cards).toHaveCount(3); - await expect(overviewPage.stats.valueFor('Requests')).toHaveText('10'); - await expect(overviewPage.stats.valueFor('Folders')).toHaveText('0'); + await expect(overviewPage.stats.valueFor('Requests')).toHaveText('41'); + await expect(overviewPage.stats.valueFor('Folders')).toHaveText('7'); await expect(overviewPage.stats.valueFor('Environments')).toHaveText('2'); }); @@ -46,18 +46,18 @@ test.describe('Collection Overview', () => { }); test.describe('Collection Configuration', () => { - test('shows the Headers, Auth, Script and Tests groups with their resolved values', async ({ overviewPage }) => { + test('shows the Headers, Auth, Script and Tests groups with their values', async ({ overviewPage }) => { const { configuration } = overviewPage; await expect(overviewPage.sectionLabel('Collection Configuration')).toBeVisible(); await test.step('the Headers group lists the collection-level header and its value', async () => { await expect(configuration.subHeading('Headers')).toBeVisible(); - await expect(configuration.rowValue('collection-header')).toHaveText('collection-header-value'); + await expect(configuration.root.getByText('collection-header-value')).toBeVisible(); }); await test.step('the Auth group shows the resolved auth mode (Bearer Token)', async () => { await expect(configuration.subHeading('Auth')).toBeVisible(); - await expect(configuration.rowValue('Mode')).toHaveText('Bearer Token'); + await expect(configuration.root.getByText('Bearer Token')).toBeVisible(); }); await test.step('the Script and Tests groups are present', async () => { diff --git a/packages/oc-docs/e2e/tests/request/request-details.spec.ts b/packages/oc-docs/e2e/tests/request/request-details.spec.ts new file mode 100644 index 0000000..8295a5f --- /dev/null +++ b/packages/oc-docs/e2e/tests/request/request-details.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '../../playwright'; + +const FILTER_BY_DATE_RANGE = ['billing', 'customers', 'Get Customers - Filter by Date Range']; + +test.describe('Request page — Details', () => { + test.beforeEach(async ({ requestPage }) => { + await requestPage.open(FILTER_BY_DATE_RANGE); + }); + + test.describe('Breadcrumb', () => { + test('shows the folder trail to the request', async ({ requestPage }) => { + const { breadcrumb } = requestPage; + await expect(breadcrumb.segment('billing')).toBeVisible(); + await expect(breadcrumb.segment('customers')).toBeVisible(); + await expect(breadcrumb.current).toHaveText('Get Customers - Filter by Date Range'); + }); + + test('returns to a parent folder when its segment is clicked', async ({ requestPage, page }) => { + await requestPage.breadcrumb.segment('customers').click(); + await expect(page.getByTestId('overview')).toBeVisible(); + }); + }); + + test('shows the request name as the page title', async ({ requestPage }) => { + await expect(requestPage.title).toHaveText('Get Customers - Filter by Date Range'); + }); + + test('shows the GET method and the request URL', async ({ requestPage }) => { + await expect(requestPage.urlBar.method).toHaveText('GET'); + await expect(requestPage.urlBar.url).toContainText('{{baseUrl}}/billing/customers'); + }); + + test('offers a "Try" action to open the request in the playground', async ({ requestPage }) => { + await expect(requestPage.urlBar.tryButton).toBeVisible(); + }); + + test('renders the request description', async ({ requestPage }) => { + await expect(requestPage.description).toContainText('Retrieves customers created within a date range.'); + }); + + test.describe('Params section', () => { + test('lists the query parameters', async ({ requestPage }) => { + const params = requestPage.section('Params'); + await expect(params).toBeVisible(); + await expect(params.getByText('created[gte]')).toBeVisible(); + await expect(params.getByText('created[lte]')).toBeVisible(); + await expect(params.getByText('per_page')).toBeVisible(); + }); + + test('shows each parameter value', async ({ requestPage }) => { + const params = requestPage.section('Params'); + await expect(params.getByText('2024-01-01')).toBeVisible(); + await expect(params.getByText('2024-12-31')).toBeVisible(); + }); + }); + + test.describe('Auth section', () => { + test('shows the auth mode inherited from the collection', async ({ requestPage }) => { + const auth = requestPage.section('Auth'); + await expect(auth).toBeVisible(); + await expect(auth.getByText('Inherited from collection')).toBeVisible(); + await expect(auth.getByText('Bearer Token')).toBeVisible(); + }); + }); + + test.describe('Code snippet', () => { + test('shows a cURL command by default', async ({ requestPage }) => { + const { codeSnippet } = requestPage; + await expect(codeSnippet.languageTab('curl')).toHaveAttribute('aria-selected', 'true'); + await expect(codeSnippet.code).toContainText('curl'); + await expect(codeSnippet.code).toContainText('/billing/customers'); + }); + + test('switches to the Python snippet when its tab is selected', async ({ requestPage }) => { + const { codeSnippet } = requestPage; + await codeSnippet.selectLanguage('python'); + await expect(codeSnippet.languageTab('python')).toHaveAttribute('aria-selected', 'true'); + await expect(codeSnippet.code).toContainText('requests'); + }); + }); +}); diff --git a/packages/oc-docs/e2e/tests/request/request-examples.spec.ts b/packages/oc-docs/e2e/tests/request/request-examples.spec.ts new file mode 100644 index 0000000..550335a --- /dev/null +++ b/packages/oc-docs/e2e/tests/request/request-examples.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '../../playwright'; + +const GET_ALL_CUSTOMERS = ['billing', 'customers', 'Get All Customers']; +const OK_EXAMPLE = '200 OK - first page'; +const BAD_REQUEST_EXAMPLE = '400 Bad Request - invalid per_page'; + +test.describe('Request page — Examples', () => { + test.beforeEach(async ({ requestPage }) => { + await requestPage.open(GET_ALL_CUSTOMERS); + }); + + test('lists every saved example', async ({ requestPage }) => { + const { examples } = requestPage; + await expect(examples.root).toBeVisible(); + await expect(examples.items).toHaveCount(2); + await expect(examples.example(OK_EXAMPLE)).toBeVisible(); + await expect(examples.example(BAD_REQUEST_EXAMPLE)).toBeVisible(); + }); + + test('shows the status code of each example', async ({ requestPage }) => { + const { examples } = requestPage; + await expect(examples.statusCode(OK_EXAMPLE)).toHaveText('200'); + await expect(examples.statusCode(BAD_REQUEST_EXAMPLE)).toHaveText('400'); + }); + + test.describe('Request pane', () => { + test('shows the query parameters by default', async ({ requestPage }) => { + const { examples } = requestPage; + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('per_page'); + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('10'); + }); + + test('switches to the Headers tab to reveal the request headers', async ({ requestPage }) => { + const { examples } = requestPage; + await examples.selectRequestTab(OK_EXAMPLE, 'headers'); + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('Accept'); + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('application/json'); + }); + + test('switches to the Auth tab to reveal the bearer auth', async ({ requestPage }) => { + const { examples } = requestPage; + await examples.selectRequestTab(OK_EXAMPLE, 'auth'); + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('Mode'); + await expect(examples.requestBody(OK_EXAMPLE)).toContainText('Bearer'); + }); + }); + + test.describe('Response pane', () => { + test('shows the response body by default', async ({ requestPage }) => { + const { examples } = requestPage; + await expect(examples.responseBody(OK_EXAMPLE)).toContainText('cus_ABC123xyz'); + await expect(examples.responseBody(OK_EXAMPLE)).toContainText('john.smith@example.com'); + }); + + test('switches to the Headers tab to reveal the response headers', async ({ requestPage }) => { + const { examples } = requestPage; + await examples.selectResponseTab(OK_EXAMPLE, 'headers'); + await expect(examples.responseBody(OK_EXAMPLE)).toContainText('x-total-count'); + await expect(examples.responseBody(OK_EXAMPLE)).toContainText('42'); + }); + }); + + test('expands a collapsed example to reveal its response', async ({ requestPage }) => { + const { examples } = requestPage; + await examples.open(BAD_REQUEST_EXAMPLE); + await expect(examples.responseBody(BAD_REQUEST_EXAMPLE)).toContainText('invalid_request'); + }); +}); diff --git a/packages/oc-docs/e2e/tests/request/request-execution-context.spec.ts b/packages/oc-docs/e2e/tests/request/request-execution-context.spec.ts new file mode 100644 index 0000000..531e2dc --- /dev/null +++ b/packages/oc-docs/e2e/tests/request/request-execution-context.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../../playwright'; + +const GET_ALL_CUSTOMERS = ['billing', 'customers', 'Get All Customers']; + +test.describe('Request page — Execution Context', () => { + test.beforeEach(async ({ requestPage }) => { + await requestPage.open(GET_ALL_CUSTOMERS); + }); + + test('shows a section with Scripts, Variables, Asserts and Tests cards', async ({ requestPage }) => { + const { executionContext } = requestPage; + await expect(requestPage.section('Execution Context')).toBeVisible(); + await expect(executionContext.scripts).toBeVisible(); + await expect(executionContext.variables).toBeVisible(); + await expect(executionContext.asserts).toBeVisible(); + await expect(executionContext.tests).toBeVisible(); + }); + + test('lists the collection → folder → request script chain around the HTTP call', async ({ requestPage }) => { + const { executionContext } = requestPage; + await expect(executionContext.script('Collection Pre-Request')).toBeVisible(); + await expect(executionContext.script('Request Pre-Request')).toBeVisible(); + await expect(executionContext.script('Collection Post-Response')).toBeVisible(); + await expect(executionContext.scripts.getByText('HTTP')).toBeVisible(); + }); + + test('shows the script execution flow', async ({ requestPage }) => { + await expect(requestPage.executionContext.scripts.getByText('Sandwich execution flow')).toBeVisible(); + }); + + test('shows the pre-request and post-response variables', async ({ requestPage }) => { + const { executionContext } = requestPage; + await expect(executionContext.variables.getByText('Pre-Request')).toBeVisible(); + await expect(executionContext.variable('expectedStatus')).toBeVisible(); + await expect(executionContext.variable('customersPath')).toBeVisible(); + await expect(executionContext.variables.getByText('Post Response')).toBeVisible(); + await expect(executionContext.variable('firstCustomerId')).toBeVisible(); + }); + + test('lists all the assertions in the execution context', async ({ requestPage }) => { + const { executionContext } = requestPage; + await expect(executionContext.assertion('res.status equals 200')).toBeVisible(); + await expect(executionContext.assertion('res.body is an array')).toBeVisible(); + await expect(executionContext.assertion('res.body.length greater than 0')).toBeVisible(); + }); + + test('lists every test case run for the request', async ({ requestPage }) => { + const { executionContext } = requestPage; + await expect(executionContext.testCase('status is 200 OK')).toBeVisible(); + await expect(executionContext.testCase('response body is a non-empty array')).toBeVisible(); + await expect(executionContext.testCase('every customer has an id and email')).toBeVisible(); + await expect(executionContext.testCase('multi-level execution chain captured')).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/e2e/tests/request/unsupported-request.spec.ts b/packages/oc-docs/e2e/tests/request/unsupported-request.spec.ts new file mode 100644 index 0000000..d64c313 --- /dev/null +++ b/packages/oc-docs/e2e/tests/request/unsupported-request.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '../../playwright'; + +const UNSUPPORTED_REQUESTS = [ + { trail: ['Realtime', 'Live Updates'], name: 'Live Updates', typeLabel: 'Websocket' }, + { trail: ['Realtime', 'GraphQL API'], name: 'GraphQL API', typeLabel: 'GraphQL' }, + { trail: ['Realtime', 'Order Service'], name: 'Order Service', typeLabel: 'gRPC' } +]; + +test.describe('Request page — unsupported request types', () => { + for (const { trail, name, typeLabel } of UNSUPPORTED_REQUESTS) { + test(`shows a "preview not available" notice for a ${typeLabel} request`, async ({ unsupportedRequestPage }) => { + await unsupportedRequestPage.open(trail); + + await expect(unsupportedRequestPage.title).toHaveText(typeLabel); + await expect(unsupportedRequestPage.breadcrumb.segment('Realtime')).toBeVisible(); + await expect(unsupportedRequestPage.breadcrumb.current).toHaveText(name); + await expect(unsupportedRequestPage.message).toContainText('Preview not available'); + await expect(unsupportedRequestPage.message).toContainText(`${typeLabel} documentation`); + }); + } +}); diff --git a/packages/oc-docs/e2e/tests/script/script.spec.ts b/packages/oc-docs/e2e/tests/script/script.spec.ts new file mode 100644 index 0000000..92685af --- /dev/null +++ b/packages/oc-docs/e2e/tests/script/script.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '../../playwright'; + +const BILLING_SCRIPT = ['billing', 'Script.js']; + +test.describe('Script page', () => { + test.beforeEach(async ({ scriptPage }) => { + await scriptPage.open(BILLING_SCRIPT); + }); + + test('shows the script name as the page title', async ({ scriptPage }) => { + await expect(scriptPage.title).toHaveText('Script'); + }); + + test('shows the folder trail to the script as a breadcrumb', async ({ scriptPage }) => { + await expect(scriptPage.breadcrumb.segment('billing')).toBeVisible(); + await expect(scriptPage.breadcrumb.current).toHaveText('Script'); + }); + + test('renders the script source as a read-only code block', async ({ scriptPage }) => { + const { content } = scriptPage; + await expect(content.root).toBeVisible(); + await expect(content.code).toContainText('user@example.com'); + await expect(content.code).toContainText('password123'); + }); + + test('offers a button to copy the script', async ({ scriptPage }) => { + await expect(scriptPage.content.copyButton).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/src/assets/icons/CloseIcon.tsx b/packages/oc-docs/src/assets/icons/CloseIcon.tsx new file mode 100644 index 0000000..0f46475 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/CloseIcon.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const CloseIcon: React.FC = () => ( + + + +); diff --git a/packages/oc-docs/src/assets/icons/ExpandIcon.tsx b/packages/oc-docs/src/assets/icons/ExpandIcon.tsx new file mode 100644 index 0000000..05c4b9a --- /dev/null +++ b/packages/oc-docs/src/assets/icons/ExpandIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const ExpandIcon: React.FC = () => ( + + + + + + +); diff --git a/packages/oc-docs/src/assets/icons/EyeOffIcon.tsx b/packages/oc-docs/src/assets/icons/EyeOffIcon.tsx new file mode 100644 index 0000000..4f071e9 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/EyeOffIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const EyeOffIcon: React.FC = () => ( + + + + +); diff --git a/packages/oc-docs/src/assets/icons/PlayIcon.tsx b/packages/oc-docs/src/assets/icons/PlayIcon.tsx new file mode 100644 index 0000000..5f58949 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/PlayIcon.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const PlayIcon: React.FC = () => ( + +); diff --git a/packages/oc-docs/src/assets/icons/index.ts b/packages/oc-docs/src/assets/icons/index.ts index 531c277..ab44141 100644 --- a/packages/oc-docs/src/assets/icons/index.ts +++ b/packages/oc-docs/src/assets/icons/index.ts @@ -4,3 +4,7 @@ export * from './OverflowIcon'; export * from './BrunoGlyph'; export * from './GlobeIcon'; export * from './BookIcon'; +export * from './ExpandIcon'; +export * from './CloseIcon'; +export * from './PlayIcon'; +export * from './EyeOffIcon'; diff --git a/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx b/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx new file mode 100644 index 0000000..1f6cd0e --- /dev/null +++ b/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { AuthDetails } from './AuthDetails'; +import { AUTH_MODE_LABELS } from '../../constants'; + +describe('AuthDetails', () => { + it('renders basic auth: mode + username, masks the password', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Basic Auth'); + expect(html).toContain('user@example.com'); + expect(html).not.toContain('s3cr3t'); + }); + + it('masks the bearer token and falls back to the raw type without a label', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('bearer'); + expect(html).not.toContain('abc123'); + }); + + it('renders apikey fields including placement', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('API Key'); + expect(html).toContain('X-Api-Key'); + expect(html).toContain('header'); + }); + + it('shows the empty message when there is no auth', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('No authentication configured'); + }); +}); diff --git a/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx b/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx new file mode 100644 index 0000000..1e86568 --- /dev/null +++ b/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import type { Auth } from '@opencollection/types/common/auth'; +import { PropertyTable, type PropertyRow } from '../PropertyTable/PropertyTable'; + +interface AuthDetailsProps { + auth?: Auth; + authModeLabels?: Record; + inheritedFrom?: string; + emptyMessage?: string; + testId?: string; +} + +const modeLabel = (auth: Auth, labels: Record): string => + auth === 'inherit' ? 'Inherit' : labels[auth.type] || auth.type; + +const pushRow = (rows: PropertyRow[], label: string, value: unknown, secret = false): void => { + if (typeof value === 'string' && value.length > 0) rows.push({ label, value, secret }); +}; + +const buildAuthRows = (auth: Exclude): PropertyRow[] => { + const rows: PropertyRow[] = []; + + switch (auth.type) { + case 'basic': + case 'digest': + case 'wsse': + pushRow(rows, 'Username', auth.username); + pushRow(rows, 'Password', auth.password, true); + break; + case 'ntlm': + pushRow(rows, 'Username', auth.username); + pushRow(rows, 'Password', auth.password, true); + pushRow(rows, 'Domain', auth.domain); + break; + case 'bearer': + pushRow(rows, 'Token', auth.token, true); + break; + case 'apikey': + pushRow(rows, 'Key', auth.key); + pushRow(rows, 'Value', auth.value, true); + pushRow(rows, 'Add To', auth.placement); + break; + case 'awsv4': + pushRow(rows, 'Access Key Id', auth.accessKeyId); + pushRow(rows, 'Secret Access Key', auth.secretAccessKey, true); + pushRow(rows, 'Session Token', auth.sessionToken, true); + pushRow(rows, 'Service', auth.service); + pushRow(rows, 'Region', auth.region); + pushRow(rows, 'Profile Name', auth.profileName); + break; + case 'oauth1': + pushRow(rows, 'Consumer Key', auth.consumerKey); + pushRow(rows, 'Consumer Secret', auth.consumerSecret, true); + pushRow(rows, 'Access Token', auth.accessToken, true); + pushRow(rows, 'Access Token Secret', auth.accessTokenSecret, true); + pushRow(rows, 'Signature Method', auth.signatureMethod); + pushRow(rows, 'Callback URL', auth.callbackUrl); + pushRow(rows, 'Placement', auth.placement); + break; + case 'oauth2': { + const o = auth as { + flow?: string; + scope?: string; + accessTokenUrl?: string; + authorizationUrl?: string; + callbackUrl?: string; + credentials?: { clientId?: string; clientSecret?: string; placement?: string }; + resourceOwner?: { username?: string; password?: string }; + }; + pushRow(rows, 'Flow', o.flow); + pushRow(rows, 'Client Id', o.credentials?.clientId); + pushRow(rows, 'Client Secret', o.credentials?.clientSecret, true); + pushRow(rows, 'Add Credentials To', o.credentials?.placement); + pushRow(rows, 'Access Token URL', o.accessTokenUrl); + pushRow(rows, 'Authorization URL', o.authorizationUrl); + pushRow(rows, 'Callback URL', o.callbackUrl); + pushRow(rows, 'Scope', o.scope); + pushRow(rows, 'Username', o.resourceOwner?.username); + pushRow(rows, 'Password', o.resourceOwner?.password, true); + break; + } + default: + break; + } + return rows; +}; + +export const AuthDetails: React.FC = ({ + auth, + authModeLabels = {}, + inheritedFrom, + emptyMessage, + testId +}) => { + if (!auth) { + return emptyMessage ? : null; + } + + const rows: PropertyRow[] = [{ label: 'Mode', value: modeLabel(auth, authModeLabels) }]; + + if (auth === 'inherit') { + if (inheritedFrom) rows.push({ label: 'Inherited From', value: inheritedFrom }); + } else { + rows.push(...buildAuthRows(auth)); + } + + return ; +}; + +export default AuthDetails; diff --git a/packages/oc-docs/src/components/ChevronArrow/ChevronArrow.tsx b/packages/oc-docs/src/components/ChevronArrow/ChevronArrow.tsx new file mode 100644 index 0000000..8985e96 --- /dev/null +++ b/packages/oc-docs/src/components/ChevronArrow/ChevronArrow.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +interface ChevronArrowProps { + open?: boolean; + size?: number; + className?: string; +} + +export const ChevronArrow: React.FC = ({ open = false, size = 13, className }) => ( + +); + +export default ChevronArrow; diff --git a/packages/oc-docs/src/components/ChevronArrow/StyledWrapper.ts b/packages/oc-docs/src/components/ChevronArrow/StyledWrapper.ts new file mode 100644 index 0000000..86044e1 --- /dev/null +++ b/packages/oc-docs/src/components/ChevronArrow/StyledWrapper.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.svg` + flex-shrink: 0; + transition: transform 0.2s ease; + + &.is-open { + transform: rotate(90deg); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +`; diff --git a/packages/oc-docs/src/components/Code/Code.tsx b/packages/oc-docs/src/components/Code/Code.tsx index aa564ab..b190e1c 100644 --- a/packages/oc-docs/src/components/Code/Code.tsx +++ b/packages/oc-docs/src/components/Code/Code.tsx @@ -1,5 +1,12 @@ -import React, { Suspense, lazy } from 'react'; -import { CodeViewer } from './CodeViewer/CodeViewer'; +import React, { Suspense, lazy, useEffect, useMemo, useRef } from 'react'; +import { CopyButton } from '../../ui/CopyButton/CopyButton'; +import { StyledWrapper } from './CodeViewer/StyledWrapper'; +import Prism from 'prismjs'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-xml-doc'; const LazyCodeEditor = lazy(() => import('../../ui/CodeEditor/CodeEditor')); @@ -10,11 +17,72 @@ interface CodeProps { onChange?: (value: string) => void; showLineNumbers?: boolean; showCopy?: boolean; + + surface?: 'base' | 'muted'; testId?: string; height?: string; className?: string; } +type CodeViewerProps = Pick; + +const CodeViewer: React.FC = ({ + code = '', + language = 'text', + showLineNumbers = false, + showCopy = true, + surface = 'base', + className, + testId +}) => { + const preRef = useRef(null); + + useEffect(() => { + if (preRef.current) { + Prism.highlightAllUnder(preRef.current); + } + }, [code, language]); + + const lineCount = useMemo(() => (code ? code.split('\n').length : 1), [code]); + + const wrapperClassName = ['code-content-wrapper overflow-hidden', surface === 'muted' ? 'code--muted' : '', className] + .filter(Boolean) + .join(' '); + const codeEl = ( +
+      {code}
+    
+ ); + + return ( + +
+ {showCopy && ( + + )} + + {showLineNumbers ? ( +
+ +
{codeEl}
+
+ ) : ( +
{codeEl}
+ )} +
+
+ ); +}; + export const Code: React.FC = ({ code = '', language = 'text', @@ -22,9 +90,10 @@ export const Code: React.FC = ({ onChange, showLineNumbers = false, showCopy = true, - testId, + surface = 'base', height = '200px', - className + className, + testId }) => { if (!readOnly) { return ( @@ -40,8 +109,9 @@ export const Code: React.FC = ({ language={language} showLineNumbers={showLineNumbers} showCopy={showCopy} - testId={testId} + surface={surface} className={className} + testId={testId} /> ); }; diff --git a/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts index 8ca2a8b..95c1d58 100644 --- a/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts @@ -1,10 +1,19 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` - max-width: 100%; - background-color: var(--code-bg); - border: 1px solid var(--oc-table-border); - border-radius: 8px; + background-color: var(--oc-background-base); + border: 1px solid var(--border-color); + border-radius: 6px; + + &.code--muted { + background-color: var(--oc-background-mantle); + border-color: transparent; + } + &.code--muted .code-content, + &.code--muted .code-content-numbered, + &.code--muted .code-content--numbered { + background-color: var(--oc-background-mantle); + } .code-copy-floating { position: absolute; @@ -20,13 +29,15 @@ export const StyledWrapper = styled.div` } .code-content { - background-color: var(--code-bg); + background-color: var(--oc-background-base); color: var(--text-primary); + scrollbar-width: thin; + scrollbar-color: var(--oc-scrollbar-color) transparent; } .code-content::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 4px; + height: 4px; } .code-content::-webkit-scrollbar-track { background: transparent; @@ -42,29 +53,31 @@ export const StyledWrapper = styled.div` } .code-content pre { - font-size: 13px; + font-size: 0.75rem; color: var(--text-primary); line-height: 1.65; } .code-content code { color: var(--text-primary); - font-size: 13px; + font-size: 0.75rem; } .code-content-numbered { display: flex; align-items: stretch; - background-color: var(--oc-bg); + background-color: var(--oc-background-base); } .code-line-numbers { flex-shrink: 0; - padding: 1rem 0 1rem 1rem; + padding: 0.5rem 0; text-align: right; user-select: none; color: var(--text-tertiary); + opacity: 0.7; } .code-line-numbers span { display: block; + padding: 0 0.625rem 0 0.75rem; font-family: 'Fira Code', var(--font-mono); font-weight: 400; font-size: 0.75rem; @@ -74,8 +87,8 @@ export const StyledWrapper = styled.div` .code-content--numbered { flex: 1; min-width: 0; - padding: 1rem 1rem 1rem 0.75rem; - background-color: var(--background-light); + padding: 0.5rem 0.875rem 0.5rem 0; + background-color: var(--oc-background-base); } .code-content--numbered pre, .code-content--numbered code { diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx new file mode 100644 index 0000000..4c802c1 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { CodeSnippetTabs } from './CodeSnippetTabs'; + +describe('CodeSnippetTabs', () => { + it('renders language tabs and the default cURL snippet', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('cURL'); + expect(html).toContain('Javascript'); + expect(html).toContain('Python'); + expect(html).toContain('curl -X POST'); + }); +}); diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx new file mode 100644 index 0000000..52d57b0 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx @@ -0,0 +1,103 @@ +import React, { useMemo, useState } from 'react'; +import type { HttpRequestBody, HttpRequestBodyVariant, HttpRequestHeader } from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; +import { Code } from '../Code/Code'; +import { CopyButton} from '../../ui/CopyButton/CopyButton'; +import { SectionLabel } from '../SectionLabel/SectionLabel'; +import { Modal } from '../../ui/Modal/Modal'; +import { ExpandIcon } from '../../assets/icons'; +import { + generateCurlCommand, + generateJavaScriptCode, + generatePythonCode, + type SnippetHeader, + type SnippetInput +} from '../../utils/codeSnippets'; +import { StyledWrapper } from './StyledWrapper'; + +interface CodeSnippetTabsProps { + method: string; + url: string; + headers?: HttpRequestHeader[]; + body?: HttpRequestBody | HttpRequestBodyVariant[]; + auth?: Auth; + className?: string; +} + +const LANGUAGES = [ + { id: 'curl', label: 'cURL', language: 'bash', generate: generateCurlCommand }, + { id: 'javascript', label: 'Javascript', language: 'javascript', generate: generateJavaScriptCode }, + { id: 'python', label: 'Python', language: 'python', generate: generatePythonCode } +] as const; + +export const CodeSnippetTabs: React.FC = ({ method, url, headers, body, auth, className }) => { + const [active, setActive] = useState(LANGUAGES[0].id); + const [expanded, setExpanded] = useState(false); + + const snippetHeaders: SnippetHeader[] = useMemo( + () => + (headers ?? []) + .filter((header) => header && header.name && header.disabled !== true) + .map((header) => ({ name: header.name, value: header.value })), + [headers] + ); + + const snippets = useMemo(() => { + const input: SnippetInput = { method, url, headers: snippetHeaders, body, auth }; + return LANGUAGES.reduce>((acc, lang) => { + acc[lang.id] = lang.generate(input); + return acc; + }, {}); + }, [method, url, snippetHeaders, body, auth]); + + const activeLang = LANGUAGES.find((lang) => lang.id === active) ?? LANGUAGES[0]; + + const renderSnippetBox = (variant: 'inline' | 'modal') => ( +
+
+
+ {LANGUAGES.map((lang) => ( + + ))} +
+ + {variant === 'inline' ? ( + + ) : ( + + )} +
+ +
+ ); + + return ( + + {renderSnippetBox('inline')} + setExpanded(false)} title={Code snippet} ariaLabel="Code snippet"> + {expanded && ( + {renderSnippetBox('modal')} + )} + + + ); +}; + +export default CodeSnippetTabs; diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts b/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts new file mode 100644 index 0000000..737b651 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts @@ -0,0 +1,92 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + .snippet-box { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + background: var(--oc-background-base); + } + + .snippet-head { + display: flex; + align-items: stretch; + min-height: 2.375rem; + padding: 0 0.375rem; + border-bottom: 1px solid var(--border-color); + } + + .snippet-tabs { + display: flex; + align-items: stretch; + } + + .snippet-tab { + display: inline-flex; + align-items: center; + padding: 0 0.625rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + transition: color 0.12s ease; + } + .snippet-tab:hover { + color: var(--text-primary); + } + .snippet-tab.is-active { + color: var(--text-primary); + font-weight: 600; + border-bottom-color: var(--primary-color); + } + .snippet-tab:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .snippet-head-spacer { + flex: 1; + } + + .snippet-box .code-content-wrapper { + border: none; + border-radius: 0; + } + + && .code-copy-floating { + opacity: 1; + } + + .code-snippet-expand { + align-self: center; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + background: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--oc-radius); + transition: color 0.12s ease; + } + .code-snippet-expand:hover { + color: var(--text-primary); + } + .code-snippet-expand:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + .snippet-copy { + align-self: center; + flex: 0 0 auto; + } +`; diff --git a/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx index db89f17..bf31db6 100644 --- a/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx +++ b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx @@ -9,7 +9,6 @@ interface CollectionStatsProps { } export const CollectionStats: React.FC = ({ stats, testId = 'collection-stats' }) => { - // Each stat card's test id extends the base testId (e.g. `collection-stats-stat`). const itemTestId = `${testId}-stat`; return ( diff --git a/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx new file mode 100644 index 0000000..ef6b0f5 --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { ContentTypeBadge } from './ContentTypeBadge'; + +describe('ContentTypeBadge', () => { + it('renders the label', () => { + expect(renderToStaticMarkup()).toContain('application/json'); + }); +}); diff --git a/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx new file mode 100644 index 0000000..00287cf --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +interface ContentTypeBadgeProps { + label: string; + className?: string; +} + +export const ContentTypeBadge: React.FC = ({ label, className }) => ( + + {label} + +); + +export default ContentTypeBadge; diff --git a/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts b/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts new file mode 100644 index 0000000..9748cd3 --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.span` + display: inline-flex; + align-items: center; + padding: 0.2rem 0.45rem; + border-radius: 0.25rem; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.6875rem; + line-height: 1; + color: var(--oc-colors-text-muted); + background-color: var(--badge-bg); +`; diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx b/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx deleted file mode 100644 index 514a854..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { TabGroup } from '../../../ui/MinimalComponents'; -import { Code } from '../../Code/Code'; -import { StyledWrapper } from './StyledWrapper'; -import { generateCurlCommand, generateJavaScriptCode, generatePythonCode } from './generateCodeSnippets'; - -interface CodeSnippetsProps { - method: string; - url: string; - headers?: Array<{ name: string; value: string; disabled?: boolean }>; - body?: { type?: string; data?: string }; -} - -export const CodeSnippets: React.FC = ({ - method, - url, - headers = [], - body -}) => { - const snippetInput = { method, url, headers, body }; - - const tabDefinitions = [ - { - id: 'curl', - label: 'cURL', - code: generateCurlCommand(snippetInput), - language: 'bash', - }, - { - id: 'javascript', - label: 'JavaScript', - code: generateJavaScriptCode(snippetInput), - language: 'javascript', - }, - { - id: 'python', - label: 'Python', - code: generatePythonCode(snippetInput), - language: 'python', - }, - ]; - - return ( - -
-

Code Snippet

-
- ({ id, label }))} - defaultTab="curl" - renderContent={(activeTab: string) => { - const tab = - tabDefinitions.find(({ id }) => id === activeTab) ?? - tabDefinitions[0]; - - return ( - - ); - }} - /> -
-
-
- ); -}; - -const ExampleCodeContent: React.FC<{ code: string; language: string }> = ({ - code, - language -}) => { - return ( -
- -
- ); -}; - diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts b/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts deleted file mode 100644 index 923dc7a..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts +++ /dev/null @@ -1,105 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - .code-example-section { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .code-example-card { - border-radius: 8px; - overflow: hidden; - background-color: var(--code-bg); - } - - .code-example-card .compact-code-view { - border: none; - border-radius: 0; - } - - .code-samples { - width: 100%; - min-width: 30%; - } - - .code-samples-frame { - width: 100%; - min-width: 30%; - height: fit-content; - } - - .code-samples-container { - width: 100%; - overflow-x: auto; - } - - .tab-header { - padding-inline: 16px; - padding-top: 8px; - background-color: var(--oc-background-surface0); - - .tab-button { - padding: 6px 0px; - border: none; - border-bottom: solid 2px transparent; - margin-right: 1.25rem; - margin-left: 0; - color: var(--oc-colors-text-muted); - cursor: pointer; - background: none; - font-size: 0.75rem; - font-weight: 500; - transition: color 0.15s ease, border-color 0.15s ease; - - &:focus, - &:active, - &:focus-within, - &:focus-visible, - &:target { - outline: none !important; - box-shadow: none !important; - } - - &:hover { - color: var(--oc-text); - } - - &.active { - color: var(--oc-text) !important; - border-bottom: solid 2px var(--primary-color) !important; - } - } - } - - .code-example-code-wrapper { - position: relative; - } - - .code-example-code-wrapper .compact-code-view { - border: none; - border-radius: 0; - background-color: transparent; - } - - .code-example-code-wrapper .compact-code-view .code-content { - padding: 32px 16px 16px; - background-color: var(--code-bg); - border-top: 1px solid var(--oc-border-border1); - } - - .code-example-code-wrapper .compact-code-view pre { - margin: 0; - } - - @media (max-width: 1100px) { - .code-samples-container { - position: static; - } - } - - .code-content-wrapper { - border-top-left-radius: 0; - border-top-right-radius: 0; - } -`; diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts b/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts deleted file mode 100644 index 8c63428..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts +++ /dev/null @@ -1,64 +0,0 @@ -interface Header { - name: string; - value: string; - disabled?: boolean; -} - -export interface CodeSnippetInput { - method: string; - url: string; - headers?: Header[]; - // Generators only emit a body when type is 'json' - body?: { type?: string; data?: unknown } | null; -} - -export const generateCurlCommand = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const headersString = headers - .filter((h) => h.disabled !== true) - .map((h) => `-H "${h.name}: ${h.value}"`) - .join(' \\\n '); - - let bodyData = ''; - if (body?.type === 'json' && body.data) { - const data = typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data); - bodyData = ` \\\n -d '${data}'`; - } - - return `curl -X ${method} "${url}"${headersString ? ` \\\n ${headersString}` : ''}${bodyData}`; -}; - -export const generateJavaScriptCode = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const enabledHeaders = headers.filter((h) => h.disabled !== true); - const headersString = enabledHeaders.map((h) => ` "${h.name}": "${h.value}"`).join(',\n'); - const bodyString = body?.type === 'json' && body.data - ? `,\n body: JSON.stringify(${typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data)})` - : ''; - - return `const response = await fetch("${url}", { - method: "${method}", - headers: { -${headersString} - }${bodyString} -}); - -const data = await response.json();`; -}; - -export const generatePythonCode = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const enabledHeaders = headers.filter((h) => h.disabled !== true); - const headersString = enabledHeaders.map((h) => ` "${h.name}": "${h.value}"`).join(',\n'); - const bodyString = body?.type === 'json' && body.data - ? `,\n json=${typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data)}` - : ''; - - return `import requests - -response = requests.${method.toLowerCase()}( - "${url}", - headers={ -${headersString} - }${bodyString} -) - -data = response.json()`; -}; diff --git a/packages/oc-docs/src/components/Docs/Docs.tsx b/packages/oc-docs/src/components/Docs/Docs.tsx index 7981d44..93f01b9 100644 --- a/packages/oc-docs/src/components/Docs/Docs.tsx +++ b/packages/oc-docs/src/components/Docs/Docs.tsx @@ -1,66 +1,34 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useMemo } from 'react'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import Sidebar from './Sidebar/Sidebar'; import Overview from '../../pages/Overview/Overview'; -import { getItemId, generateSafeId } from '../../utils/itemUtils'; -import { isFolder } from '../../utils/schemaHelpers'; -import { useAppSelector } from '../../store/hooks'; -import { selectSelectedItemId } from '../../store/slices/docs'; +import Request from '../../pages/Request/Request'; +import Script from '../../pages/Script/Script'; +import { findItemByUuid, getAncestorsByUuid } from '../../utils/fileUtils'; +import { isHttpRequest, isScriptFile, isUnsupportedRequest } from '../../utils/schemaHelpers'; +import { useAppSelector, useAppDispatch } from '../../store/hooks'; +import { selectSelectedItemId, selectItem } from '../../store/slices/docs'; interface DocsProps { docsCollection: OpenCollectionCollection | null; - filteredCollectionItems: any[]; + /** Retained for API compatibility; the sidebar reads collection items from the store. */ + filteredCollectionItems?: unknown[]; onOpenPlayground?: () => void; } -const Docs: React.FC = ({ - docsCollection, - filteredCollectionItems, -}) => { +const Docs: React.FC = ({ docsCollection, onOpenPlayground }) => { + const dispatch = useAppDispatch(); const selectedItemId = useAppSelector(selectSelectedItemId); - const isInitialMount = useRef(true); - // Scroll to selected item when it changes (but not on initial load) - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - if (selectedItemId && filteredCollectionItems.length > 0) { - // Find the item by UUID to get its safe ID for scrolling - const findItemForScroll = (items: any[]): any => { - for (const item of items) { - const itemUuid = (item as any).uuid; - const itemId = getItemId(item); - const safeId = generateSafeId(itemId); - - // Check if this is the selected item - if (itemUuid === selectedItemId || safeId === selectedItemId || itemId === selectedItemId) { - return { item, safeId }; - } - - // If it's a folder, search recursively - if (isFolder(item) && item.items) { - const found = findItemForScroll(item.items); - if (found) return found; - } - } - return null; - }; + const selected = useMemo( + () => (docsCollection && selectedItemId ? findItemByUuid(docsCollection.items, selectedItemId) : null), + [docsCollection, selectedItemId] + ); - const result = findItemForScroll(filteredCollectionItems); - if (result) { - // Scroll to the item after a short delay to ensure DOM is updated - setTimeout(() => { - const element = document.getElementById(`section-${result.safeId}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, 100); - } - } - }, [selectedItemId, filteredCollectionItems]); + const ancestry = useMemo( + () => (docsCollection && selectedItemId ? getAncestorsByUuid(docsCollection, selectedItemId) : []), + [docsCollection, selectedItemId] + ); return ( <> @@ -76,16 +44,27 @@ const Docs: React.FC = ({ -
- {docsCollection && ( +
+ {docsCollection && (isHttpRequest(selected) || isUnsupportedRequest(selected)) ? ( + onOpenPlayground?.()} + onBreadcrumbClick={(uuid) => dispatch(selectItem(uuid))} + /> + ) : docsCollection && isScriptFile(selected) ? ( +